Skip to main content
Back to Research

State Management Without Redux in React

Production useReducer+Context patterns for complex admin state: slice-based reducers, localStorage persistence, Zod validation, and when to reach for…

Abstract

Redux is often adopted before it's needed, and avoided after it's needed. The middle path — useReducer with React Context, structured with slice-based reducers and Zod-validated persistence — handles substantial complexity without the Redux overhead. This article uses the JootaCee CMS admin panel as a concrete case study.

January 10, 2026
7 min read

The State Management Spectrum

State management exists on a spectrum of complexity:

  • useState for component-local state that doesn't need to be shared
  • useReducer for complex state transitions in a single component
  • useReducer + Context for state shared across a component tree
  • Zustand or Jotai for state shared across independent component trees
  • Redux Toolkit for state that needs time-travel debugging, middleware, and ecosystem tooling

Most applications spend too much time at the extremes: either useState everywhere (leading to prop drilling and complex sync logic) or Redux immediately (leading to boilerplate for features that didn't need it). The useReducer + Context combination occupies the productive middle ground for most admin interfaces, dashboards, and complex forms.

The Admin State Pattern

JootaCee's admin panel manages: site configuration, design tokens, content visibility, navigation structure, analytics state, and CMS settings. This is substantial state — 15+ nested objects — with complex interdependencies. Redux seemed like the obvious choice. Context seemed too simple. The actual solution is in between.

The state shape:

// src/lib/admin/types.ts
export type AdminPanel =
  | 'dashboard'
  | 'config'
  | 'content'
  | 'design'
  | 'analytics'
  | 'github'
  | 'studio';

export type AdminState = {
  activePanel: AdminPanel;
  siteConfig: SiteConfig;
  seoConfig: SeoConfig;
  designConfig: DesignConfig;
  contentBlocks: ContentBlock[];
  navbarConfig: NavbarConfig;
  analytics: AnalyticsState;
  // ... additional domains
};

export type AdminAction =
  | { type: 'SET_ACTIVE_PANEL'; payload: AdminPanel }
  | { type: 'UPDATE_SITE_CONFIG'; payload: Partial<SiteConfig> }
  | { type: 'UPDATE_DESIGN_TOKENS'; payload: Partial<DesignTokens> }
  | { type: 'TOGGLE_CONTENT_BLOCK'; payload: { id: string; visible: boolean } }
  | { type: 'RESET_ALL' }
  // ... 70+ action types

The discriminated union for AdminAction is the foundation of the pattern. Every state transition is explicitly typed. TypeScript catches calls like dispatch({ type: 'TYPO_IN_TYPE', payload: ... }) at compile time. Adding a new action requires adding to the union — the exhaustiveness check in the reducer then surfaces every handler that needs updating.

Slice-Based Reducers

A monolithic reducer for 70+ action types is unreadable. Slice-based reducers split handling by domain:

// src/lib/admin/slices/design.ts
import type { AdminState, AdminAction } from '../types';

export function designSlice(state: AdminState, action: AdminAction): AdminState {
  switch (action.type) {
    case 'UPDATE_DESIGN_TOKENS':
      return { ...state, designConfig: { ...state.designConfig, tokens: { ...state.designConfig.tokens, ...action.payload } } };
    case 'SET_COLOR_PALETTE':
      return { ...state, designConfig: { ...state.designConfig, activePalette: action.payload } };
    case 'RESET_DESIGN':
      return { ...state, designConfig: createInitialState().designConfig };
    default:
      return state;
  }
}

// src/lib/admin/store.tsx
const SLICE_HANDLERS: Array<(state: AdminState, action: AdminAction) => AdminState> = [
  navigationSlice,
  designSlice,
  contentSlice,
  configSlice,
  analyticsSlice,
  // ...
];

function adminReducer(state: AdminState, action: AdminAction): AdminState {
  return SLICE_HANDLERS.reduce(
    (currentState, sliceHandler) => sliceHandler(currentState, action),
    state
  );
}

Each slice handler returns a new state object if it handles the action, or the unchanged state if it doesn't. The root reducer composes all slices by folding over them. This is deliberately similar to Redux's combineReducers but without Redux as a dependency.

Each slice is independently testable — pass a state object and an action, assert on the returned state. No store setup, no mock providers, no testing infrastructure beyond Vitest.

localStorage Persistence

Auto-save to localStorage with debounce prevents state loss on browser close:

// In the AdminProvider component
const [state, dispatch] = useReducer(adminReducer, undefined, loadState);

// Debounced persistence
useEffect(() => {
  const timeout = setTimeout(() => {
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
    } catch (err) {
      reportError(err, { context: 'admin:persist' });
    }
  }, 800);

  return () => clearTimeout(timeout);
}, [state]);

function loadState(): AdminState {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return createInitialState();

    const json = JSON.parse(raw);
    const result = AdminStateSchema.partial().safeParse(json);

    if (!result.success) {
      reportError(new Error('Admin state validation failed'), {
        context: 'admin:load',
        issues: result.error.issues,
      });
      return createInitialState();
    }

    // Merge validated partial state with current defaults
    return { ...createInitialState(), ...result.data };
  } catch {
    return createInitialState();
  }
}

The 800ms debounce prevents a storage write on every keypress for form inputs. The AdminStateSchema.partial().safeParse() pattern (covered in the Zod article) handles schema migration gracefully — missing fields fall back to defaults rather than crashing.

Avoiding Context Re-render Pitfalls

The classic Context performance problem: the entire context value changes on every state update, causing every consumer to re-render even when the specific state slice they care about didn't change.

The solutions, in order of preference:

1. Split contexts by update frequency. Separate AdminStateContext (state) from AdminDispatchContext (dispatch). The dispatch function never changes (it's stable from useReducer). Components that only dispatch actions subscribe to dispatch context and never re-render from state changes.

const AdminStateContext = createContext<AdminState | null>(null);
const AdminDispatchContext = createContext<Dispatch<AdminAction> | null>(null);

// Components that only dispatch:
function ResetButton() {
  const dispatch = useContext(AdminDispatchContext)!;
  return <button onClick={() => dispatch({ type: 'RESET_ALL' })}>Reset</button>;
  // Never re-renders due to state changes
}

2. Memoize context values. If multiple related values must be in the same context, memoize the context object:

const contextValue = useMemo(() => ({ state, dispatch }), [state, dispatch]);
<AdminContext.Provider value={contextValue}>

3. Use use-context-selector for granular subscriptions. For contexts with large state objects where only a slice is needed, the use-context-selector library provides selector-based subscriptions:

// Only re-renders when state.designConfig.activePalette changes
const palette = useContextSelector(AdminStateContext, s => s.designConfig.activePalette);

When to Use Zustand Instead

Zustand is the right choice over useReducer + Context when:

  • State needs to be accessed from outside the React component tree (Web Workers, utility functions, test setup)
  • Multiple independent component trees need to access the same state (multiple React roots)
  • The context re-render problem is real and the memoization solutions above haven't resolved it
  • You want built-in middleware (persist, devtools) without implementing it yourself

Zustand's create function produces a store with a stable interface. Subscriptions are selector-based by default — components only re-render when their selected slice changes. The persistence middleware handles localStorage integration with less manual work than the pattern above.

Do not use Zustand because it "feels simpler." The useReducer pattern with typed actions has a significant advantage: the exhaustive action types make it impossible to dispatch invalid actions or forget to handle a case in the reducer. Zustand's mutation-based approach doesn't have this constraint. For complex domains where correctness matters, the useReducer typing discipline is worth the verbosity.

The Case Study: JootaCee Admin Panel

The jootacee.com admin panel implements this pattern at production scale: 70+ typed actions, 10 domain slices, localStorage persistence with Zod validation, and split state/dispatch contexts. The key numbers:

  • The root reducer file (store.tsx) is under 100 lines
  • Each slice file handles 5-12 action types and is under 80 lines
  • Adding a new action requires: 1 addition to the AdminAction union in types.ts, 1 case in the relevant slice, and 1 case in the Zod schema enum
  • The entire admin state structure is validated on every browser load — invalid data from old schema versions is caught, logged, and defaulted gracefully

The pattern scales. The cost is upfront typing discipline. The payoff is a state system where TypeScript tells you about every call site that needs updating when you change the state shape — before you merge, before you ship, before a user encounters a crash.

Continue Reading
JCJOOTACEE / OPS

Operational laboratory for AI systems, automation infrastructures, and modular digital ecosystems.

Systems

  • AURA Orchestration
  • MCP Ecosystem
  • Graph Memory
  • AI Agents
  • Docker Infrastructure
  • Industrial Intelligence

System Status

PlatformOperational
APIHealthy
3D EngineActive
MCP Nodes8 Online

Try the Konami code...

Stay in the loop

Occasional updates on AI systems, autonomous infrastructure, and new releases.

© 2026 JootaCee. All systems operational.

RSSChangelogNext.js 16 + React 19 + R3F + GSAP