Adetayo Akinsanya unkletayo.dev

State Management Architecture: Normalized Stores, Signals, XState Automata, and Immutability

Deconstructing state topologies, atomic reactivity, fine-grained signals, and finite state machines

Part 6 in Series — Catch up on the previous article: React Architecture Under the Hood: Fiber Tree Reconciliation, Priority Scheduling, and Concurrent Mode (Part 5) before diving into this post.

State management is the single most frequent source of architectural complexity in client-side applications. As web applications scale, uncontrolled state mutations cause cascade re-renders, race conditions, and out-of-sync UI components.

Architecting a clean state layer requires categorizing state topologies and selecting the appropriate reactivity primitives: Flux/Redux normalized stores, atomic state, fine-grained Signals, or Finite State Machines.


1. Taxonomy of Frontend State Topologies

Not all client state belongs in a single global store. A robust architecture separates state into four distinct tiers:

+-----------------------------------------------------------------------+
|                        Frontend State Topologies                      |
| +-------------------------+ +---------------------------------------+ |
| |    Local View State     | |          Lifted/Shared State          | |
| | (isOpen, hover, focus)  | |  (Active Tab, Accordion Selections)   | |
| +-------------------------+ +---------------------------------------+ |
| +-------------------------+ +---------------------------------------+ |
| |   Global App State      | |         Server Cache State            | |
| | (User Auth, Preferences)| | (API Entities, Query Cache, Refetch)  | |
| +-------------------------+ +---------------------------------------+ |
+-----------------------------------------------------------------------+
  1. Local View State: Transient UI state confined to a single component (e.g., dropdown toggle). Managed via component-local state hooks (useState).
  2. Lifted Shared State: UI state shared between sibling components (e.g., active step in a multi-stage wizard). Passed down via explicit props or lightweight Context.
  3. Global App State: Client-wide operational settings (e.g., authenticated user session, active theme).
  4. Server Cache State: Remote database entities mirrored on the client (e.g., product lists, user profiles). Must be managed via specialized query libraries (TanStack Query, SWR) equipped with cache invalidation, deduplication, and refetching strategies.

2. Flux Pattern & Normalized Store Architecture

When complex relational entities are stored as deeply nested arrays in global state, updating a single item requires expensive deep cloning and risks data inconsistency across components.

The Flux/Redux Pattern resolves this by enforcing unidirectional data flow and relational entity normalization (storing entities like a database using ID keys).

[ Action ] ---> [ Dispatcher ] ---> [ Reducer (Immutable Store) ] ---> [ Subscribed Views ]

Normalized Entity Store Schema

// Relational Entity State Normalization
export interface NormalizedState {
  users: {
    byId: Record<string, { id: string; name: string; email: string }>;
    allIds: string[];
  };
  comments: {
    byId: Record<string, { id: string; authorId: string; content: string }>;
    allIds: string[];
  };
}

// Updating a single comment author requires O(1) dictionary update without mutating nested trees
function updateAuthorName(state: NormalizedState, userId: string, newName: string): NormalizedState {
  return {
    ...state,
    users: {
      ...state.users,
      byId: {
        ...state.users.byId,
        [userId]: {
          ...state.users.byId[userId],
          name: newName
        }
      }
    }
  };
}

3. Fine-Grained Reactivity: Signals vs Virtual DOM Diffing

Traditional React re-renders execute top-down: when parent state updates, React re-executes the parent component function and recursively diffs Virtual DOM subtrees.

Signals (popularized by SolidJS, Preact, and Angular) replace top-down Virtual DOM diffing with fine-grained dependency graph tracking.

Virtual DOM Approach (React):
[ State Change ] ---> [ Re-render Component ] ---> [ VDOM Diff Tree ] ---> [ Patch Real DOM ]

Signals Approach (Preact/Solid/Preact Signals):
[ Signal Write ] --------------------------------------------------------> [ Direct Real DOM Node Update ]

How Signals Work (Observable Dependency Subscription)

A Signal consists of a getter function and a setter function. When a Signal is read inside a reactive context (a component or effect), it registers the current context directly into its internal subscriber set.

// Micro-Implementation of a Fine-Grained Signal Engine
type EffectContext = () => void;
let activeEffect: EffectContext | null = null;

export function createSignal<T>(initialValue: T) {
  let value = initialValue;
  const subscribers = new Set<EffectContext>();

  const read = (): T => {
    if (activeEffect) {
      subscribers.add(activeEffect); // Automatically register dependency!
    }
    return value;
  };

  const write = (newValue: T): void => {
    if (value !== newValue) {
      value = newValue;
      // Notify ONLY direct subscribers (No tree-wide re-renders!)
      subscribers.forEach((effect) => effect());
    }
  };

  return [read, write] as const;
}

export function createEffect(effectFn: EffectContext) {
  activeEffect = effectFn;
  effectFn(); // Execute once to capture dependencies
  activeEffect = null;
}

4. Eliminating Impossible States with Finite State Machines (XState)

Boolean flags (isLoading: boolean, isError: boolean, isSuccess: boolean) lead to invalid UI states (isLoading: true AND isError: true simultaneously).

Finite State Machines (FSM) enforce explicit states and valid transition triggers:

// XState Machine Definition for Data Fetching Architecture
import { createMachine, interpret } from "xstate";

export const fetchMachine = createMachine({
  id: "fetcher",
  initial: "idle",
  states: {
    idle: {
      on: { FETCH: "loading" }
    },
    loading: {
      on: {
        RESOLVE: "success",
        REJECT: "failure"
      }
    },
    success: {
      on: { FETCH: "loading" }
    },
    failure: {
      on: { RETRY: "loading" }
    }
  }
});

// Runtime Guard Enforcement:
const service = interpret(fetchMachine).start();
console.log(service.state.value); // 'idle'

service.send({ type: "RESOLVE" }); 
// INVALID TRANSITION IGNORED! State remains 'idle' because RESOLVE is invalid in 'idle' state.

Summary & Key Takeaways

  • State Taxonomy: Isolate Server Cache State (managed via TanStack Query) from Global App State and Local View State.
  • Store Normalization: Store relational entities using key-value dictionaries (byId, allIds) to prevent deep immutability cloning and duplicate data anomalies.
  • Signals Architecture: Bypasses top-down Virtual DOM diffing by linking reactive variables directly to target DOM nodes via automatic dependency collection.
  • Finite State Machines: Use statecharts (XState) for complex user flows (multi-step checkouts, authentication streams) to eliminate impossible boolean flag combinations.

References & Further Reading

  1. Redux Official Docs. Normalizing State Shape Architecture. Redux Docs.
  2. Preact Docs. Signals Specification and Reactivity Model. Preact Engineering.
  3. XState Documentation. Finite State Machines and Statecharts in JavaScript. Stately AI.

Up Next in Series →

Part 7: Frontend API Layer Architecture: REST, GraphQL, gRPC-Web, and Backend-for-Frontend (BFF) Pattern

Continue to Part 7 →