Decoupling UI from side effects in complex React apps for fun and profit :)

By Nick Ribal, February 2026

Nick Ribal

image/svg+xml

Front-end/web consultant & mentor

Family

Remote work

Opinions :)

  • React hooks suck!

  • Separating state and logic from view

  • Benefits to UI

  • Benefits to logic

  • Benefits to testing

  • Examples
  • Q&A

Topics

React hooks suck!

  • Dependency arrays put the responsibility on the developers

  • Mistakes are expensive, hard to detect, inspect and debug:

    • Over-subscription = bad perf and their "fix" is more hooks

    • Under-subscription = stale data

  • Keeping track of state together with logic is HARD with all the hooks mechanics

  • "Rules of hooks" suck

  • Standard debugging tools are useless. React DevTools extension? That's not something you want!

"Do one thing, do it well. Compose."

  • Focusing ONLY on UI

  • Focusing ONLY on logic

  • Focusing ONLY on gluing and testing where the two meet:

    • In the app

    • In Storybook stories

    • In tests

UI=fn(props) is a GOOD thing!

Separating UI from logic allows:

I didn't invent anything and almost every UI runtime uses MV*. Except the web and especially React.

Separating view from logic is MVVM

"Model" in this context means browser and other APIs:

  • Back-end interfaces
  • Network cache
  • Storage
  • Cookies
  • Web sockets
  • etc.
  • Avoid state in components
  • Avoid side effects in components
  • Pass data + model bindings into components

UI = fn(props)

  • No useState, useEffect, useCallback and friends
  • I don't ever want to care about rendering/mounting - that's the (view)model's concern
  • Yes model/view bindings
  • Yes rich (view)models

Practical rules

  • Custom hooks are a poor man's View Model
  • TanStack query as example:
    • Exists because hooks suck
    • Popular because everyone needs it
    • Still complex, complicated and conceptually wrong. We want the UI to be the end result of our world state and not the trigger

We want UI to be trivially dumb!

Hooks, the good parts

Think UI = fn(props) instead of hooks, their dumb rules and dependency arrays

  • React is good, if you avoid the bad parts: hooks and their many many many pitfalls. The ecosystem is great!
  • MobX does one thing: change detection. It fixes what is missing in React: reactivity
  • MobX is where application logic, data and side effects live
  • MobX is:
    • unopinionated: structure code, files, modules, abstractions and coding style however you want
    • UI agnostic: is it JS, so it runs in DOM, RN, Node, etc.
    • typesafe and battle tested at large scale
    • synchronous and doesn't need additional tooling - just JS!

My recipe for MVVM in React

MobX isn't special: it was the first one to get it right

// Remember how before react this used to Just Work?
debugger; // It still does in MobX!

State & mutations

Instead of useState, use classes or POJOs to model your domain state and mutations

class UserModel {
  firstName = '';
  // Explicit mutation
  setFirstName = (firstName: typeof this.firstName): void => {
    this.firstName = firstName;
  };

  lastName = '';
  // Note the inferred type of argument and return type
  setLastName = (lastName: typeof this.lastName): void => {
    this.lastName = lastName;
  };

  constructor({ firstName, lastName }: UserModelProps) {
    makeAutoObservable(this); // Adds MobX reactivity 
    
    this.setFirstName(firstName);
    this.setLastName(lastName);
  }
}

Views and derivations

We often need to have properties which depend on values of other properties.

For example, if we need a fullName prop, which concatenates `firstName` + space + `lastName`, we may try this:

class UserModel {
  firstName = ''
  lastName = ''
+ fullName = ''

- setFirstName = (firstName: typeof this.firstName): void => { this.firstName = firstName }
+ setFirstName = (firstName: typeof this.firstName): void => {
+   Object.assign(this, { firstName, fullName: `${firstName} ${this.lastName}` })
+ }

- setLastName = (lastName: typeof this.lastName): void => { this.lastName = lastName }
+ setLastName = (lastName: typeof this.lastName): void => {
+   Object.assign(this, { lastName, fullName: `${this.firstName} ${lastName}` })
+ }
}

But duplicating data requires synchronization, risks staleness and conflicts if one forgets the required bookkeeping - especially with code branching and async updates. In addition, you may encounter race conditions and a lack of a single source of truth. There's a better way.

This is where bugs live

Single source of truth: canonical data + derivations

Define the minimal amount of model data, which cannot be reduced any further and then derive different "views" from that "canonical data".

When the data changes, the derived and reactive views will change too and your UI will re-render in the most efficient way possible, by definition.

class CustomerModel {
  firstName = '';
  lastName = '';
  isActive = false;
  email = '';
  // Views which react when properties change
  get fullName(): string {
    return `${this.firstName} ${this.lastName}`;
  }
  // Views can contain any logic you want and be as complex as necessary
  get hasValidName(): boolean {
    return this.firstName.trim() !== '' && this.lastName.trim() !== '';
  }
  get hasValidEmail(): boolean {
    return /some impossible regex here/.test(this.email.trim());
  }
  // Views can depend on other views
  get isValidUser(): boolean {
    return this.hasValidName && this.hasValidEmail;
  }
  get canMakePurchase(): boolean {
    return this.isActive && this.isValidUser;
  }
}

Model with views

MobX bindings

You can read all these properties without any additional ceremonies:

These derivations are performed synchronously, so in case of an exception, we get a meaningful error and a readable stack trace.

console.info(customerInstance.canMakePurchase) // Logs false.

This example works fine without MobX. MobX only adds reactivity: any property/view which is read anywhere in the code will automatically be updated when the data changes - without any additional code!

With MobX (via mobx-react) bindings, you never have over or under-subscriptions and the UI updates are as granular as possible!

Modern apps are rich in interactions and require complex UI logic and wiring - there is plenty of pure UI code to maintain and reason about without biz logic also getting in the way.

Separation of concerns

Separating biz logic from UI logic sets a natural boundary and explicit API between the two and allows each to be tested and refactored separately, without leaking implementation details.

Managing complexity

Testing UI

A dumb and stateless UI is extremely easy to test:

  • Storybook + Chromatic is all you need, which tests PIXELS instead of implementation details
  • Never use React Context (it is just a fancy name for a global variable)
  • Never need mocks: instead populate your VM with data
  • Just render: UI = fn(props)

When the biz logic is free of UI/rendering mechanics, it is easy and FAST to test: no need to bundle UI code, no need to launch a slow and resource hungry renderer or browser - just to assert a few strings! Tests are cheap to write and run in milliseconds.

Testing biz logic

Examples

import type { Meta, StoryObj } from '@storybook/react';
import { ServiceName } from '../../constants/ServiceName';
import { ExitCriteriaPickerModel } from '../../models/exitCriteriaPicker/ExitCriteriaPickerModel';
import { ExitCriteriaAdminCard, type ExitCriteriaAdminCardType } from './ExitCriteriaAdminCard';

const exitCriteriaPickerModel = ExitCriteriaPickerModel.create({
  entityFormModel: null,
  stages: [
    { labelText: 'Stage 1', value: 'Stage 1' },
    { labelText: 'Stage 2', value: 'Stage 2' },
    { labelText: 'Stage 3', value: 'Stage 3' },
    { labelText: 'A very long stage name that will overflow', value: 'A very long stage name that will overflow' },
    { labelText: 'Stage 5', value: 'Stage 5' },
    { labelText: 'Stage 6', value: 'Stage 6' },
    { labelText: 'Stage 7', value: 'Stage 7' },
    { labelText: 'Stage 8', value: 'Stage 8' },
  ],
  activeCrmPlatform: ServiceName.Salesforce,
  stageTaskTemplatesModel: null,
});

const meta: Meta<ExitCriteriaAdminCardType> = {
  component: ExitCriteriaAdminCard,
  args: {
    exitCriteriaPickerModel,
  },
} as const;

export default meta;

export const Blank: StoryObj<typeof ExitCriteriaAdminCard> = {} as const;

Storybook story with VM

Model tests

import { describe, expect, it } from 'vitest';

import { getDuplicatedTitle, NotesCardModel, UNTITLED_NOTE_TITLE } from './NotesCardModel';

describe(NotesCardModel.name, () => {
  describe('getDuplicatedNoteTitle', () => {
    const noteTitle = "Making sense of this client's demands";
    const duplicatedOnceTitle = getDuplicatedTitle(noteTitle);

    it('should return "Copy of {noteTitle}" when it is the first copy', () => {
      expect(duplicatedOnceTitle).toBe(`Copy of ${noteTitle}`);
    });

    const duplicatedTwiceTitle = getDuplicatedTitle(duplicatedOnceTitle);
    it('should return "Copy 2 of {noteTitle}" when it is the second copy', () => {
      expect(duplicatedTwiceTitle).toBe(`Copy 2 of ${noteTitle}`);
    });

    it('should return "Copy 3 of {noteTitle}" when it is the third copy', () => {
      const duplicatedThriceTitle = getDuplicatedTitle(duplicatedTwiceTitle);
      expect(duplicatedThriceTitle).toBe(`Copy 3 of ${noteTitle}`);
    });

    it(`should return "Copy of ${UNTITLED_NOTE_TITLE}" if input is empty string`, () => {
      const duplicateOfBlankTitle = getDuplicatedTitle(' ');
      expect(duplicateOfBlankTitle).toBe(`Copy of ${UNTITLED_NOTE_TITLE}`);
    });
  });
});

Complex model tests

import { describe, expect, it } from 'vitest';

import { ServiceName } from '../../constants/ServiceName';
import { getOrCreateCrmEntityStore } from '../../stores/CrmEntityStore';
import { CtrlUserStore } from '../../stores/CtrlUserStore';
import { getOrCreateHubSpotStore } from '../../stores/HubSpotStore';
import { getOrCreatePipelineStore } from '../../stores/PipelineStore';
import { getOrCreateSalesforceStore } from '../../stores/SalesforceStore';
import { ApolloClientModel } from '../ApolloClientModel';
import { HubSpotEntityFetcherModel } from '../hubspot/HubSpotEntityFetcherModel';
import { HubSpotPipelineEntityTypeModel } from '../hubspot/HubSpotPipelineEntityTypeModel';
import { SalesforceEntityFetcherModel } from '../salesforce/SalesforceEntityFetcherModel';
import { SalesforcePipelineEntityTypeModel } from '../salesforce/SalesforcePipelineEntityTypeModel';

import { PipelineColumnModel } from './PipelineColumnModel';
import { PipelineModel } from './PipelineModel';

const apolloClientModel = ApolloClientModel.create({});

function createRuleFromField(): null {
  return null;
}

const ctrlUserStore = CtrlUserStore.create({ apolloClientModel });

const pipelineStore = getOrCreatePipelineStore({
  crmEntityStore: getOrCreateCrmEntityStore({
    crmEntitiesFetchers: {
      [ServiceName.Salesforce]: SalesforceEntityFetcherModel.create({ salesforceStore: getOrCreateSalesforceStore({ ctrlUserStore }) }),
      [ServiceName.HubSpot]: HubSpotEntityFetcherModel.create({ hubspotStore: getOrCreateHubSpotStore({ ctrlUserStore }) }),
    },
    ctrlUserStore,
  }),
  crmPlatforms: {
    [ServiceName.Salesforce]: SalesforcePipelineEntityTypeModel.create(),
    [ServiceName.HubSpot]: HubSpotPipelineEntityTypeModel.create(),
  },
  ctrlUserStore,
});

describe(PipelineModel.name, () => {
  describe('columnOrderWithAllColumnsIncluded', () => {
    it('includes all columns even if no order was set', () => {
      const pipelineModel = PipelineModel.create({
        apolloClientModel,
        availableColumns: [
          PipelineColumnModel.create({ isRemovable: true, isSortable: true, labelText: 'Column One', name: 'columnOne' }),
          PipelineColumnModel.create({ isRemovable: true, isSortable: true, labelText: 'Column Two', name: 'columnTwo' }),
        ],
        createRuleFromField,
        ctrlUserStore,
        pipelineStore,
      });

      expect(pipelineModel.columnOrderWithAllFlattenedColumnsIncluded).toEqual(['columnOne', 'columnTwo']);
    });

    it('includes all columns if some are present in "columnOrder" and some are not', () => {
      const pipelineModel = PipelineModel.create({
        apolloClientModel,
        availableColumns: [
          PipelineColumnModel.create({ isRemovable: true, isSortable: true, labelText: 'Column One', name: 'columnOne' }),
          PipelineColumnModel.create({ isRemovable: true, isSortable: true, labelText: 'Column Two', name: 'columnTwo' }),
        ],
        columnOrder: ['columnTwo'],
        createRuleFromField,
        ctrlUserStore,
        pipelineStore,
      });

      expect(pipelineModel.columnOrderWithAllFlattenedColumnsIncluded).toEqual(['columnTwo', 'columnOne']);
    });

    it('does not duplicate columns when already present in "columnOrder"', () => {
      const pipelineModel = PipelineModel.create({
        apolloClientModel,
        availableColumns: [
          PipelineColumnModel.create({ isRemovable: true, isSortable: true, labelText: 'Column One', name: 'columnOne' }),
          PipelineColumnModel.create({ isRemovable: true, isSortable: true, labelText: 'Column Two', name: 'columnTwo' }),
        ],
        columnOrder: ['columnOne', 'columnTwo'],
        createRuleFromField,
        ctrlUserStore,
        pipelineStore,
      });

      expect(pipelineModel.columnOrderWithAllFlattenedColumnsIncluded).toEqual(['columnOne', 'columnTwo']);
    });
  });

  describe('visibleColumnNames', () => {
    it('returns the names of columns marked as visible in "columnVisibility"', () => {
      const pipelineModel = PipelineModel.create({
        apolloClientModel,
        columnVisibility: { columnOne: true, columnTwo: true, three: false },
        createRuleFromField,
        ctrlUserStore,
        pipelineStore,
      });

      expect(pipelineModel.visibleColumnNames).toEqual(['columnOne', 'columnTwo']);
    });

    it('returns the names of available columns omitted from "columnVisibility"', () => {
      const pipelineModel = PipelineModel.create({
        apolloClientModel,
        availableColumns: [
          PipelineColumnModel.create({ isRemovable: true, isSortable: true, labelText: 'Column One', name: 'columnOne' }),
          PipelineColumnModel.create({ isRemovable: true, isSortable: true, labelText: 'Column Two', name: 'columnTwo' }),
        ],
        columnVisibility: {},
        createRuleFromField,
        ctrlUserStore,
        pipelineStore,
      });

      expect(pipelineModel.visibleColumnNames).toEqual(['columnOne', 'columnTwo']);
    });

    it('returns the names of columns marked as visible and columns omitted from "columnVisibility"', () => {
      const pipelineModel = PipelineModel.create({
        apolloClientModel,
        availableColumns: [
          PipelineColumnModel.create({ isRemovable: true, isSortable: true, labelText: 'Column One', name: 'columnOne' }),
          PipelineColumnModel.create({ isRemovable: true, isSortable: true, labelText: 'Column Two', name: 'columnTwo' }),
          PipelineColumnModel.create({ isRemovable: true, isSortable: true, labelText: 'Three', name: 'three' }),
        ],
        columnVisibility: { columnOne: true, columnTwo: false },
        createRuleFromField,
        ctrlUserStore,
        pipelineStore,
      });

      expect(pipelineModel.visibleColumnNames).toEqual(['columnOne', 'three']);
    });
  });
});

Thank you!

Questions & discussion

Made with Slides.com