A lightweight React state management implementation demonstrating clean separation of concerns through normalized state, semantic actions, and layered architecture. Built as an educational exploration of solving state management at the component-dispatcher boundary without external dependencies.
This codebase implements a custom state management pattern that completely decouples components from state logic. Components interact through a semantic interface that hides all implementation details – state structure, action types, and transformation logic remain invisible to the presentation layer.
The system uses a layered architecture where each module has a single, well-defined responsibility:
- Components (
App.jsx) - Purely presentational logic, no knowledge of state structure or action types - Hooks (
hooks.js) - Semantic interface exposing state slices and action methods - Dispatchers (
dispatchers.js) - Pure functions handling state transformations - Reducer (
reducer.js) - Thin delegation layer routing actions to appropriate dispatchers - Provider (
Provider.jsx) - Context wrapper managing state and dispatch distribution
Actions are expressed as domain-specific methods (itemCreated, itemUpdated, itemDeleted) rather than raw dispatch calls. Components describe what they want to happen, not how it happens. This abstraction allows the underlying action structure (types, payloads) to change without touching component code.
State uses a byId/allIds pattern for efficient lookups and updates:
{
byId: { 1: { id: 1, value: "Item 1" }, 2: { ... } },
allIds: [1, 2]
}This eliminates O(n) lookups, prevents data duplication, and enables direct entity access by ID.
Dispatchers are organized as a pluggable object registry rather than a monolithic switch statement. New operations are added by creating dispatcher functions and registering them by key, enabling extensibility without modifying existing code.
Components have zero awareness of:
- Context implementation details
- State shape or structure
- Action types or payload formats
- Dispatcher existence or implementation
The entire state management system could be swapped out by rewriting only the hooks – components remain unchanged.
State updates use Object.assign({}, ...) to create new objects only where necessary. Unchanged portions of state maintain their references, allowing React to skip reconciliation for components using unmodified data.
State flows down through context, actions flow up through dispatch. No two-way binding or direct mutations exist anywhere in the system. This makes state changes predictable and traceable.
src/
├── App.jsx # Main application with component examples
├── Provider.jsx # Context provider wrapping useReducer
├── hooks.js # useActions, useItem, useAllItemIds
├── dispatchers.js # ADD_ITEM, UPDATE_ITEM, DELETE_ITEM handlers
├── reducer.js # Dispatcher delegation logic
├── data.js # Initial normalized state
└── main.jsx # Application entry point
import { useActions, useItem } from './hooks'
function Component({ id }) {
const item = useItem(id)
const actions = useActions()
const handleUpdate = () =>
actions.itemUpdated(id, 'New value')
return (
<div>
<p>{item.value}</p>
<button onClick={handleUpdate}>Update</button>
<button onClick={() => actions.itemDeleted(id)}>Delete</button>
</div>
)
}Components consume a clean API without knowing:
- That actions dispatch
{ type: 'UPDATE_ITEM', payload: {...} } - That state is normalized with
byId/allIds - That dispatchers exist or how they transform state
- Separation of Concerns: Each module handles one responsibility
- Semantic Abstraction: Actions describe business intent, not technical operations
- Hidden Implementation: Components never import dispatchers or action types
- Pure Transformations: Dispatchers are pure functions with no side effects
- Stable Contracts: Hook signatures provide unchanging API boundaries
- Explicit Flow: State changes only occur through explicit dispatch calls
- Maintainability: Changes to state logic don't cascade to components
- Testability: Dispatchers are pure functions easily tested in isolation
- Scalability: Normalized structure and reference preservation support growing datasets
- Extensibility: New operations added without modifying existing code
- Predictability: Unidirectional flow makes debugging straightforward
- Flexibility: Internal implementation can evolve independently from component code
This pattern solves state management at the component-dispatcher boundary rather than bringing in a full state management library. It provides the core benefits of Redux (predictable updates, separation of concerns) without the overhead of middleware, DevTools integration, or time-travel debugging.
Context re-renders all consumers when any part of state changes. For applications with many components and frequent updates, consider:
- Splitting context into multiple providers for different state domains
- Using selectors with
useMemofor derived state - Implementing React.memo for expensive component trees
The current architecture prioritizes clarity and maintainability over premature optimization.
Appropriate for:
- Learning how state management patterns work internally
- Small to medium applications with straightforward state needs
- Projects where external dependencies should be minimized
- Teams wanting full control over state management implementation
Consider alternatives for:
- Large applications needing DevTools or middleware ecosystems
- Complex async workflows requiring sophisticated side effect handling
- Applications with performance-critical frequent updates across many components
This implementation demonstrates solving state management at the appropriate abstraction level. Rather than reaching for external libraries, it shows how to build architectural boundaries that keep components focused on presentation while state logic remains isolated and testable.
The patterns here – normalized state, semantic actions, dispatcher registries, and hook-based abstractions – are transferable concepts that apply regardless of the specific state management tool used in production.