Nick Ribal
Front-end veteran, consultant and freelancer, who's armed with a decade of experience solving challenges on the Web platform for large Internet companies.
By Nick Ribal, February 2026
Opinions :)
React hooks suck!
Separating state and logic from view
Benefits to UI
Benefits to logic
Benefits to testing
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.
"Model" in this context means browser and other APIs:
UI = fn(props)
// Remember how before react this used to Just Work?
debugger; // It still does in MobX!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);
}
}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.
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;
}
}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.
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.
A dumb and stateless UI is extremely easy to test:
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.
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;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}`);
});
});
});
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']);
});
});
});By Nick Ribal
Many teams struggle with increasingly complex React codebases. Hooks like useEffect and useCallback introduce fragile dependencies, blur the line between logic and presentation, and make components harder to reason about, test, and maintain. At the same time, traditional component tests often focus on implementation details rather than what actually matters: what users see on screen.
Front-end veteran, consultant and freelancer, who's armed with a decade of experience solving challenges on the Web platform for large Internet companies.