Recent articles About

Compiling enterprise

Ivan Koshelev blog on software development

SolidJS and UI architecture Taking web apps to the next level [2023 January 30] SolidJS, ui, architecture, stores, state-stores, ViewModels

This is an article about SolidJS, a rising JS library for creating web applications and about my idea of good UI architecture worked out during the years.

SolidJS combines fast and reliable reactivity based on tracking every data read and write (like KnockoutJS and MobX), with the elegance of JSX based templates (like React) and Store-based approach to state (like Redux). SolidJS piqued my interest, because these specific patterns have proved to be best in my 10+ years of Web development, and SolidJS does not just bring them together in a cohesive manner, it uses advances in JavaScript and takes them to the next level. Accessing and tracking state is done via Proxies, which allows our components to work with pieces of state that are readonly, but always stay up to date with the Store. This means you can pass them around like you would normal JS objects. It also means, that your components can track dependencies of every computation or HTML element binding in a maximally fine-grained manner, for example, if you render a string from inside your Store in a div element - SolidJS will capture exact property providing this value as dependency for text of the div, and when you update that property inside the Store - text of the element will be synchronously updated. This also means, Virtual DOM is not needed (you can even have HtmlElements detached from DOM, yet still react to state changes) and rendering is super fast!

import { render } from "solid-js/web";
import { createSignal } from "solid-js";
import { createStore, produce } from "solid-js/Store";

// signals are like useState, but you can create
// them anywhere!
const [count, setCount] = createSignal(1);
const increment = () => setCount(count() + 1);

// SolidJS stores are like Redux stores, but better ;-)
const [state, setState] = createStore({
   counters: {
      counterA: {
          value: 1,

// you can take an isolated piece of original state,
// and it will stay connected to the original Store,
// so you can pass it anywhere, as if it was a plain JS object
const stateOfJustCounterA = state.counters.counterA;

// Components can receive such state via props, but also access
// it directly - no matter how a Component got the reference -
// it will track it and update HTML elements with it.
function Counter(props: { name: string }) {
   return (
         // SolidJS comes with batteries included,
         // "produce" works in the same way as Immer,
         // letting you work with otherwise immutable Store state
         // as if it was mutable, and handling diffs behind the scenes
         onClick={() =>
            setState(produce((draft) => (draft.counters.counterA.value += 1)))

   () => (
      <Counter name="I'm attached to DOM!" />
         onClick={() => console.log((detachedElement as any)[1].innerText)}
         log state of <b>detached</b> element

// This element is not even attached to DOM (yet), but it also updates!
let detachedElement = <Counter name="I'm detached from DOM!" />;   
SolidJS playground

In 2021, I experimented with Svelte, which follows a similar reactivity approach, but Svelte team decided to go with a custom dialect of JS and HTML and custom approach to ESM modules. In my experience, going against JS conventions never ends well, so, sadly, I couldn't advocate the use of Svelte. SolidJS, on the other hand, makes sure to follow JS conventions and delivers everything I wanted in a UI library. If you haven't tried SolidJS yet - I urge you to check-out their learning materials (excellent I might add) and in 2-4 hours you will be ready to use SolidJS.

In this article I will go over a showcase implementation of "Hetman Motors" - a minimalist synthetic UI application for car dealership managers. Few years ago I brought together a set or realistic enterprise app requirements designed to really test new ui libraries / architectures against reality, a kind of TodoMVC taken to the next level. I've tried a dozen implementation so far, 4 of them successful, and Solid JS takes the crown for now. In this article I will explain architectural decisions that pertain specifically to SolidJS and Store-based libraries in general.

Before we delve into the architecture, I will explain couple important concepts.

🔗 Hard state VS soft (derived, computed) state

In enterprise apps it is very important to distinguish between hard and soft state. Soft state is anything that can be re-computed from the hard state. For example, when you have base price of vehicle and a list of selected insurance plans (which increase final price of the deal) - that is "hard" state. The final price of the deal, which is computed from them, is "soft", since you can always recompute it.

When dealing with the two kinds of state, I follow two guiding principles. The most important principle: Stores are only for hard state, computed state must never be stored in Stores. Trying to somehow manually cache soft state almost always leads to bugs. There is also no technical reason to do so, since plenty of libraries offer flexible memoization functions, and SolidJS comes with dedicated createMemo, which will make sure that any recomputation only happens when underlying hard state actually changes. Clear separation of hard and soft state makes reasoning about code a lot easier.

Principle number two is not strictly related to UI, but to enterprise apps in general. It is the inverse of principle number one: When you are manipulating business state in API / Data Store - you must save both hard state and soft state displayed to the user, for audit purposes. This is because from the users perspective, there are no models, layers and APIs - there is only the UI they see, and that UI is most likely a legally binding public offer. Imagine a hypothetical situation: one of the calculation formulas for insurance rates has an error in it, subtracting something that should have been added. A user books a deal, and the system shows them the price. After that, the error gets fixed and when billing time comes - user receives a bigger bill than expected. Without the logging of soft state from the time of deal creation, it would be impossible to figure out the truth, this can lead to a dissatisfied customer and reputation damage. P.S. if you are the one fixing bugs in calculations - please remember about consequences like these and avoid them upfront.

In summary: keep hard state in Stores, recompute soft state via memoized functions, record both for audit purposes when user performs business-relevant action.

🔗 SubStore

As you know, a typical Store in a Store-based applications contains a tree of state several layers deep, but most components must not know about all of that state, just a small part of it. As an example, in our car dealership app the Store contains a list of all deals we are working on, but a component rendering a given deal must only know about one of them. In many cases, the component shouldn't even know, wether the deals are stored in an array or in a an record object where deal ids are keys. All it wants is a function to get relevant sate of the deal it must render and a function to mutate that specific deal. Having just these two functions saves it from excessive knowledge and prevent it from accidentally mutating unrelated state.

This is where a concept of SubStore comes in. SubStore combines a normal Store with a getter function that selects a part of it's state. Unlike a classical Redux selector, this function does not produce any derived state, it only selects a part of existing state tree, and it also applies to Store updates as much as to Store reads.

import { render } from "solid-js/web";
   import { createStore, produce, SetStoreFunction } from "solid-js/Store";
   // utility functions
   export type SubStore<TState> = readonly [
     (update: (draft: TState) => void) => void
   export function getSubStoreFromStore<TStore, TSubStore>(
     [store, setStore]: [TStore, SetStoreFunction<TStore>],
     getter: (store: TStore) => TSubStore
   ): SubStore<TSubStore> {
     return [
       (update: (draft: TSubStore) => void) =>
           produce((draft) => {
             const substoreDraft = getter(draft);
     ] as const;
   // demonstration
   type Counter = {
     value: number;
   type CoutnersStoreRoot = {
     counters: Record<`counter${string}`, Counter>;
   const [rootState, setRootState] = createStore<CoutnersStoreRoot>({
     counters: {
       counterA: {
         value: 1,
       counterB: {
         value: 1,
   function CounterComponent(props: { counterStore: SubStore<Counter> }) {
     return (
           onClick={() => props.counterStore[1]((x) => (x.value += 1))}
   const counterASubstore = getSubStoreFromStore(
     [rootState, setRootState],
     (store) => store.counters.counterA
     () => <CounterComponent counterStore={counterASubstore} />,
SolidJS playground

🔗 "pure" and "almost-pure"

In the article I use the term "almost-pure". "Pure" here comes from functional programming and means that a given function has no dependencies except it's parameters and does not have side-effects, only returns a value. "Almost-pure" keeps this spirit, but relaxes two of the requirements.

First, such function can capture some of its dependencies, mostly singletons like backend client, from module imports, to avoid passing the same singleton at every call site. This is allowed because almost nothing is truly static in JS and mocking imports during testing is very easy.

Second, such function is allowed to mutate state that was passed in parameters directly instead of returning a new version of that state. This is allowed because these functions expect to be passed a "draft" of new state and mutate it like any JS object. This lets us keep the code concise and avoid constant spreads and array filters like

return { 
   someArray: oldState.someArray.filter(x => !== idToRemove)

instead using short and clear syntax of

lodash.remove(draftState.someArray, x => === idToRemove);

and let SolidJS produce new immutable state of our Store when we are finished with the draft. Overall, such function is slightly more flexible than a strictly-pure one, because you can always make it strictly-pure like this:

const newState = cloneDeep(oldState);
return newState;

but you can't easily do the reverse and make a strictly-pure function mutate existing state (you'd have to compute the diff).

🔗 Architecture overview

Our application has 5 layers, each higher layer "knows" about all layers beneath it. In technical terms this means it can import it. Code of the showcase application is available on GitHub and deployed version is available here.

SolidJS Components

Components are only responsible for visualizing state that is kept in Stores. They don't contain any business logic, don't contain almost any hard state of their own and almost never call backend directly. Most components will receive Stores or ViewModels as parameters, only root components may reference root Store instances directly. They are also responsible for handling events that come from user interaction with UI, extracting business data from them and passing it to lower layers.

The role of Components is to produce HTML based on VMs and Stores. Any logic more complex than choosing, which child Component or Element to render, should probably go into VMs and Pure Business Logic layers.

ViewModels (pure adapters to Store state)

ViewModels here differ from ViewModels of classical MVVM, they are pure, contain no state of their own. Main task of a VM is to bind together (compose) pieces of state from Store with pure and almost-pure functions from lower layers. VMs provide a unified object through which Components access hard state from the Store, derived state and business logic functions that change state synchronously and via asynchronous Flows. VMs are similar to the approach taken by Rematch library, but being based on SolidJS SubStores rather than Redux Stores allows them to be more fine-grained and easily reusable.

This layer is also where many of the async functions handling Flows will be defined. Flows represent out-of-process async interactions, for example, API calls, that manipulate Store state more than once over time. They fit into VM layer well, because they tend to rely on more than one Store or SubStore, much like VMs themselves, but they are independent of VMs. That is, VMs compose Flows, but Flows don't rely on VMs.

The role of VMs is to compose everything that is needed by Components into a cohesive object, encapsulate fine-grained state manipulation and expose only higher-level business operations to the Components layer.

Stores and SubStores

Stores represent all the state in your application. They are mostly identical to Redux Stores: you only keep serializable POJO state in them and you keep only the hard state that is not computed. Biggest difference with Redux is that you are not forced to have ALL the state in one object - you can have several root Stores split by the kind of data kept in them or as independent sub-applications.

Entire UI of your application must be completely derived from the content of its Stores, to the point where saving a serialized snapshot of all Stores and then restoring it allows you to perform time-travel debugging of UI. The one exception can be made for the most transient of state, for example, a bool indicating if a dropdown is expanded or not. This approach of "state of UI is derived from one or few well known POJO objects" is very good in enterprise applications, because it allows to quickly extract and examine the complete hard state of you application, while ignoring lots of secondary soft state. This makes understanding and debugging the app A LOT easier. For example, QAs will be able to send you the snapshots right before the bug and during the bug, and comparing the snapshot alone will allow you to instantly deduce the source of the bug.

This layer also contains some of the simpler Flows, which only rely on a single Store. Please also note, while information about running Flows can be persisted in Stores (as we will see below), the Flows themselves represent out-of-process calls, so, restoring a snapshot does not restart the Flows that were active, when the snapshot was made. If you intend to serialize and re-hydrate state as part of normal operation - make sure to do it between Flows or handle active Flows separately when re-hydrating.

The role of Stores is to contain the entire hard state of your app, to the point where putting a snapshot of state into the Store completely restores the application to the point in time when the snapshot was taken.

Models and pure Business Logic

This layer contains model types and business logic function that make up your Domain. This layer does not care about the library you are using to make your UI, or that there even is a UI. It comprises of pure and almost-pure functions that would look the same, whether you ran them in a browser or inside NodeJS server. Majority of your unit testing will target this layer.

The role of Models and BL layer is to contain plain JS definition of your Domain in types and pure functions.

Server client

This layer contains definition of your server API models and endpoints. It must be generated from a machine-readable definition like Open API or distributed as an NPM package. In a good project, you must NEVER maintain this by hand. You goal is this: "If the app compiled against the latest API client - we know there will be no model / url / method / header etc... mismatches when calling our API."

The role of Server client layer is to provide strong-typed auto-generated client for your API.

Code in the Server client layer is quite typical (and usually generated), let us look at code in layers 2-5.

🔗 Models and pure Business Logic layer

In layer 2 you will find code to create a new blank Deal. In this application, Deal means purchase of car with insurance options. The following screen says it all:

export const DealTag: `Deal${string}` = 'Deal';

export const createBlankDeal = () => ({
      type: DealTag,

      businessParams: {
         dealId: 0,
         isDealFinalized: false,
         downpayment: getUserInputState<number, any>(0),
         insurancePlansSelected:  getUserInputState<InsurancePlan[], any>([]),
         carModelSelected:  getUserInputState<CarModel | undefined, any>(undefined),
      isClosed: false,
      activeFlows: {} as Record<
         | `loading:car-models`
         | `loading:insurance-plans`
         | `loading:downpayment`
         | `loading:approval`
         | `loading:finalizing`, true>,
      insurancePlansAvailable: [] as InsurancePlan[],
      carModelsAvailable: [] as CarModel[],
      messages: [] as DisplayMessage[],

export type Deal = ReturnType<typeof createBlankDeal>;

export type DealBusinessParams = Deal['businessParams'];

// typical pure function in this layer
export function canRequestMinimumDownpayment(deal: DealBusinessParams) {
   return deal.carModelSelected.committedValue
       && deal.isDealFinalized === false;

Here are things of note.

const DealTag: `Deal${string}` = 'Deal'; is a polymorphic tag, a JSON-compatible way to tag hierarchial data structures. It's explained in more detail in this article . In short:

type BaseDeal = {
   // the ${string} part makes sure, that derived tags can be assigned into it
   type: `Deal${string}`; 
   price: number;

type DealWithForeignCurrency = BaseDeal & {
   type: `Deal;DealForeignCurrency${string}`;
   currency: string;

let baseDeal: BaseDeal = {} as any;

let dealWithForeignCurrency: DealWithForeignCurrency = {} as any;

// assignable, since dealWithForeignCurrency and its type extend BaseDeal;
baseDeal = dealWithForeignCurrency;

// not assignable without a guard-check
dealWithForeignCurrency = baseDeal;
TS playground

Together with multimethods, such tags allow us to have standalone functions with polymorphism.

export const getFinalPrice = multimethod('type', DealTag, (deal: Deal) => {

   const basePriceUSD = deal.businessParams.carModelSelected.committedValue?.basePriceUSD;

   if (!basePriceUSD ) {
         return 0;

   const priceIncrease = deal
         .map(x => basePriceUSD * x.rate)
         .reduce((prev, cur) => prev + cur, 0);

   return basePriceUSD + priceIncrease;

// notice, that we are adding new function to the existing definition
// the new function will be called for deals where type === DealForeignCurrencyTag;
getFinalPrice.override(DealForeignCurrencyTag, function (deal: DealForeignCurrency) {
   const basePriceUSD = this.base(deal);
   const finalPrice = basePriceUSD
         * deal.businessParams.foreignCurrencyHandlingCoefficient
         * deal.exchangeRate;

   return Math.round(finalPrice);

// invocation, just like any other function

downpayment: getUserInputState(0), constructs a receptacle for incoming user data, typically an HTML Input element or a widget. Complicated input forms are everywhere in enterprise and this approach to input state is built to handle very feature-rich input requirements.

type UserInputState<TModel, TInput = any> = {
   // type, for easy identification of input state
   __type: `solidjsdemo:UserInputState${string}`;
   // value of the input
   committedValue: TModel;
   // value of the input, while the user is still typing/choosing
   uncommittedValue: {
       value: TInput;
   } | undefined;
   // initial value to support reset and "isChanged" 
   pristineValue: TModel;
   // flag indicating, if the input ever received user focus
   isTouched: boolean;
   activeFlows: Record<string, true>;
   // messages to be shown under the input
   messages: DisplayMessage[];

Finally, there is the activeFlows property. In a lot of projects i've seen a property like "isLoading" used to block the UI. It's set to true when an API is called and then to false, when the call finishes. The problem with this approach appears, when two or more calls are made at the same time - they both set property to true, but the first one finishing resets it to false, and the UI is unblocked while the call is still ongoing. I found it's much better to record individual flows state like this:

type ourType = {
   activeFlows: {} as Record<
   | `loading:car-models`
   | `loading:insurance-plans`
   | `loading:downpayment`
   | `loading:approval`
   | `loading:finalizing`, true>

// when you need a combined "isLoading"
export function hasActiveFlows(
      state: { activeFlows: Record<string, true> }) {
      return Object.keys(state.activeFlows).length > 0;

// a utility function to run Flows
export async function runFlow<TRes, T extends string>(
      setStore: SubStore<{ activeFlows: Record<T, true> }>[1], 
      reason: T, 
      fn: () => Promise<TRes>){

      setStore(x => addActiveFlow(x, reason));

      let res: any;
      try {
         return await fn();
      } finally {
         setStore(x => removeActiveFlow(x, reason)); 

// a simple Flow function (typically resides in Store / VM layer)
export async function reloadAvailableCarModels(
      dealStore: SubStore<Deal>) {

      const [deal, setDeal] = dealStore;

      await runFlow(setDeal, 'loading:car-models', async () => {
         const carModels = await carInventoryClient.getAvailableCarModels();
         setDeal(x => x.carModelsAvailable = carModels);

🔗 Stores layer

This layer is very simple: most of the business logic functions are either one layer lower, or one layer higher. Here is a typical Store:

export function getDefaultDealsStoreRoot(){
   return {
     nextDealId: 1,
     deals: [] as Deal[],
     activeDealId: undefined as number | undefined,
     newDealIsLoading: false
 // this function allows you to create a SubStore for just the active deal (active tab)
 export function getActiveDealSubstore(store: SubStore<DealsStoreRoot>){
   return getDeeperSubStore(store, s => => x.businessParams.dealId === s.activeDealId)!);

The most notable thing in this layer is the Clock Store. For most enterprise apps it makes sense to consider "now" date part of the state and increment it discreetly, usually once per second. Among other things, this makes sure that all timers in you UI tick in sync.

export function getDefaultClockStoreRoot() {
   return {
      currentDate: new Date(),
      tickIntervalHandle: undefined as (number | undefined)

export type ClockStoreRoot = ReturnType<typeof getDefaultClockStoreRoot>;

export function start(
      clockStore: SubStore<ClockStoreRoot>
) {

   const [clock, setClock] = clockStore;

   if (clock.tickIntervalHandle !== undefined) {
      try {
      } catch (ex) {


   const tickIntervalHandle = setInterval(() => {
      setClock((newState) => {
         newState.currentDate = new Date();;
   }, 1000) as unknown as number;

   setClock((newState) => {
      newState.tickIntervalHandle = tickIntervalHandle;
      newState.currentDate = new Date()

🔗 ViewModels layer

In this layer we define the most complex Flows and also compose functions from lower levels into reusable objects. The goal of a VM is to encapsulate derived state computation and direct data manipulation into higher-level business operations and expose them to the Components layer. Here is a typical VM:

export function dealVM<T extends Deal>(
   dealStore: SubStore<T>,
   dealsStore: SubStore<DealsStoreRoot>,
   approvalStore: SubStore<ApprovalsStoreRoot>,
   clockStore: SubStore<ClockStoreRoot>) {

   const [deal, setDeal] = dealStore;
   const [deals, setDeals] = dealsStore;
   const [approvals, setApprovals] = approvalStore;
   const [clock, setClock] = clockStore;

   console.log(`Creating dealVM for ${deal.businessParams.dealId}`);

   const currentApproval = createMemo(() => getLatestMatchingApproval(

   const dealProgressState = createMemo(() => getDealProgressState(

   const generalValidation = createMemo(() => getGeneralValidation(deal));

   return {
    // hard state, readonly
    state: deal,
    // soft state, readonly, lazy
    derivedState: {
     currentDate: () => clock.currentDate,
     // note, that we are able track active flows in a fine-grained manner
     isCurrentApprovalLoading: () => approvals.activeFlows[`loading:${deal.businessParams.dealId}`],
     canRequestMinimumDownpayment: () => canRequestMinimumDownpayment(deal.businessParams),
     finalPrice: () => getFinalPrice(deal),
     canBeFinalized: () => canBeFinalized(
     isLoading: () => isLoading(deal),
     isActiveDeal: () => deals.activeDealId === deal.businessParams.dealId,
     canRequestApproval: () => canRequestApproval(deal)
    // sub view-models, mostly used to configure user inputs
    // note, that none of the hard state of the inputs is saved here,
    // only configuration of validation and user-input parsing
    subVMS: {
     insurancePlansSelected: getUserInputVM<InsurancePlan[]>(
         getDeeperSubStore(dealStore, x => x.businessParams.insurancePlansSelected),
         (m) => => x.description).join(", ")),
     carModelSelected: getUserInputVM<CarModel | undefined>(
         getDeeperSubStore(dealStore, x => x.businessParams.carModelSelected),
         (m) => m?.description ?? ""),
     downpayment: getNumericInputVM(
         getDeeperSubStore(dealStore, x => x.businessParams.downpayment),
    // business operations, many of them Flows 
    setThisDealAsActive: () => setDeals(x => x.activeDealId = deal.businessParams.dealId),
    reloadAvailableCarModels: () => reloadAvailableCarModels(dealStore),
    reloadAvailableInsurancePlans: () => reloadAvailableInsurancePlans(dealStore),
    setMinimumPossibleDownpayment: () => setMinimumPossibleDownpayment(dealStore),
    requestApproval: () => requestApproval(dealStore, approvalStore),
    finalizeDeal: () => finalizeDeal(dealStore, approvalStore),
    removeThisDeal: () => setDeals(x => removeDeal(x, deal.businessParams.dealId))

This is also the layer, wher most of the Flows are defined.

export async function setMinimumPossibleDownpayment(
   dealStore: SubStore<Deal>) {

   const [deal, setDeal] = dealStore;

   await runFlow(setDeal, 'loading:downpayment', async () => {
       if (!areDealBusinessParamsValid(deal.businessParams)) {

       // note, that this function is a "multimethod", and will
       // seamlessly change the logic base on the type of Deal passed to it
       const minPayment = await getMinimumPossibleDownpayment(deal);

       setDeal(x => {
           x.businessParams.downpayment.pristineValue = minPayment;

🔗 Components layer

Components are simple and dumb. For the most part, they will received a VM and directly bind to methods / properties on it.

export const DealComponentBare = (props: {
   vm: DealVM
}) => {

   // parts omitted 

   return <>
         <div class='car-purchase-model-selector-label'>
            Please select model
         <CarModelsSelector vm={props.vm} />
         <div class='car-purchase-insurance-selector-label'>
            Please select insurance options
         <InsurancePlanSelector vm={props.vm} />
         <div class='car-purchase-downpayment-label'>
            Please select downpayment
            inputAttributes={{ class: 'car-purchase-downpayment' }}
            messageAttributes={{ class: 'car-purchase-downpayment-messages' }}
               || props.vm.state.businessParams.isDealFinalized}
               || !props.vm.derivedState.canRequestMinimumDownpayment()}
            onClick={() => props.vm.setMinimumPossibleDownpayment()}
            Set minimum possible
         // parts omitted

If you wonder, why is the component so flat, checkout this article.

🔗 Conclusion

The Architecture is very easy to reason about and unit test (check GitHub repository). All logic is contained in pure / almost-pure standalone functions and all state is POJOs. UI Components are very simple, since VMs provide encapsulation of lower level Business Logic and only expose high level derived state / methods to them. VM are very simple because they are not based on JS classes (which are often hard to reason about) and they are very easy to test since they are pure. Keeping state in Stores saves a lot of time on debugging. Polymorphic Tags and multimethods allow you to leverage all the advantages of polymorphism in function and inheritance in data structures, while avoiding their typical problems (stemming from rigid class hierarchies) and keeping your code functional. And SolidJS makes sure that your app stays maximally efficient through all of it.

Ivan Koshelev photo

I'm passionate for anything that can be programed and automated to make life better for all of us.


  1. January 2023 (1)
  2. January 2022 (2)
  3. November 2021 (1)
  4. May 2021 (1)
  5. March 2020 (1)
  6. August 2019 (1)
  7. July 2019 (1)
  8. May 2019 (1)
  9. February 2019 (1)
  10. October 2017 (1)
  11. August 2017 (3)
  12. July 2017 (3)
  13. May 2017 (3)


  1. GitHub@IKoshelev
  2. LinkedIn
  3. NuGet@IKoshelev
  4. NPM@IKoshelev