DiegoVallejo

Deterministic Mutation: Implementing Transactional State in React

The Problem: State Entropy in Complex UIs

In sophisticated React applications, state management often degrades into Entropy. When multiple sources—websockets, user inputs, background workers—mutate a single state object, the UI loses its "source of truth." We are left with "ghost" changes, difficult-to-track dirty states, and expensive re-renders.

Standard useState is insufficient for transactional flows because it lacks an Identity Map. Without a reference to the original "Clean" state, calculating whether a form is dirty requires deep-equal checks that scale poorly ( complexity).

The Solution: The Transactional Model

A Transactional Model separates state into three layers:

  1. Base State (Identity Map): The immutable reference point from the last server sync or save.
  2. Current State (Draft): The volatile state undergoing mutations.
  3. Commit/Rollback Logic: The mechanism to reconcile or discard the Draft.

1. The useDirtyState Hook

The primary objective is to minimize re-renders while providing an dirty check. By using a ref for the base state, we avoid closure staleness and unnecessary dependency tracking.

import { useState, useCallback, useMemo, useRef } from 'react';

export function useDirtyState<T extends object>(initialState: T) {
  const [current, setCurrent] = useState<T>(initialState);
  const baseState = useRef<T>(initialState);

  // O(n) shallow diff - optimized for top-level form structures
  const isDirty = useMemo(() => {
    const keys = Object.keys(current) as Array<keyof T>;
    for (const key of keys) {
      if (current[key] !== baseState.current[key]) return true;
    }
    return false;
  }, [current]);

  const mutate = useCallback((patch: Partial<T>) => {
    setCurrent(prev => ({ ...prev, ...patch }));
  }, []);

  const commit = useCallback(() => {
    baseState.current = { ...current };
    setCurrent({ ...current }); // Trigger re-render to reset isDirty
  }, [current]);

  const rollback = useCallback(() => {
    setCurrent({ ...baseState.current });
  }, []);

  return { state: current, isDirty, mutate, commit, rollback };
}

2. The HOC Approach: Injecting Sanity into Legacy

For components where you cannot easily refactor internal logic to hooks, the Higher-Order Component (HOC) provides a transactional wrapper. It encapsulates the "Draft" logic and provides the wrapped component with a standardized interface.

Architectural Note: The HOC pattern is particularly effective for large-scale data tables where each row requires its own isolation level before a "Save All" operation.
import React, { ComponentType, useState, useCallback, useMemo, useRef } from 'react';

export interface TransactionalProps<T> {
  data: T;
  isDirty: boolean;
  mutate: (patch: Partial<T>) => void;
  commit: () => void;
  rollback: () => void;
}

export function withTransactionalState<T extends object, P extends TransactionalProps<T>>(
  WrappedComponent: ComponentType<P>,
  initialData: T
) {
  return function TransactionalStore(props: Omit<P, keyof TransactionalProps<T>>) {
    const [current, setCurrent] = useState<T>(initialData);
    const baseState = useRef<T>(initialData);

    const isDirty = useMemo(() => {
      return JSON.stringify(current) !== JSON.stringify(baseState.current);
    }, [current]);

    const mutate = useCallback((patch: Partial<T>) => {
        setCurrent(prev => ({ ...prev, ...patch }));
    }, []);

    const commit = useCallback(() => {
      baseState.current = current;
      setCurrent({ ...current });
    }, [current]);

    const rollback = useCallback(() => {
      setCurrent(baseState.current);
    }, []);

    return (
      <WrappedComponent 
        {...(props as P)} 
        data={current} 
        isDirty={isDirty} 
        mutate={mutate} 
        commit={commit} 
        rollback={rollback} 
      />
    );
  };
}

When to use which?

  • Use the Hook when building new functional components or forms. It allows for better composition with other hooks like useEffect for auto-saving.
  • Use the HOC when dealing with class components or when you need to provide the same state logic across several disparate components in a shared registry.

Conclusion: Atomicity is the Goal

Complex data flows shouldn't result in fragile UIs. By implementing Transactional Wrappers, we move the burden of "Dirty State Management" from the developer to the architecture. This ensures that the UI remains a deterministic projection of the data, allowing for instant rollbacks and clear validation boundaries.


"State management is not about changing values; it is about managing the history of those changes."