Skip to main content
May 22, 2026 15 min read

createContextState: A Small React State Manager for Product UI

A practical introduction to createContextState from khanglvm/react: scoped stores, selector subscriptions, Immer updates, snapshots, and the Navigos Talent One use case.

React State Management Architecture Open Source

Most React state does not need a custom state manager.

Server data usually belongs in a cache such as TanStack Query. Small UI state usually belongs in useState near the component that owns it. A custom state layer starts to make sense in the middle: state that is not server cache, but is still shared by several components, pages, or feature modules.

createContextState is my small answer for that middle layer.

It is a typed state-management factory built on React Context, useSyncExternalStore, and Immer. You give it a state shape. It gives you a Provider and hooks for selected reads, writes, latest snapshots, and optional optimistic rollback.

I built the pattern while working on Navigos Talent One, a large recruitment platform with multiple product modules, language switching, long editing flows, campaign tools, search pages, and feature-owned state boundaries. The public version lives in khanglvm/react, so the examples in this post use simplified public code instead of product code.

TL;DR: quick usage

Use createContextState when a page or feature has shared client state and you want:

  • A Provider scoped to the part of the tree that needs the state.
  • Selector-based subscriptions so components re-render only when their selected value changes.
  • Immer draft updates instead of repeated object spreading.
  • A latest-state snapshot getter for async callbacks.
  • An optional revert function for optimistic UI.
import { createContextState } from "@/state/createContextState"

type JobDraftState = {
  step: "details" | "requirements" | "preview"
  draft: {
    title: string
    skills: string[]
  }
  saving: boolean
  error?: string
}

export const {
  Provider: JobDraftStateProvider,
  useContextStateValue: useJobDraftValue,
  useSetContextState: useSetJobDraftState,
  useStateSnapshotGetter: useJobDraftSnapshot,
} = createContextState<JobDraftState>("JobDraft")

export function JobDraftPage({ children }: { children: React.ReactNode }) {
  return (
    <JobDraftStateProvider
      initialState={{
        step: "details",
        draft: { title: "", skills: [] },
        saving: false,
      }}
    >
      {children}
    </JobDraftStateProvider>
  )
}

export function StepIndicator() {
  const step = useJobDraftValue((state) => state.step)
  return <span>{step}</span>
}

export function AddSkillButton({ skill }: { skill: string }) {
  const setState = useSetJobDraftState(false)

  return (
    <button
      onClick={() => {
        setState((draft) => {
          draft.draft.skills.push(skill)
        })
      }}
    >
      Add skill
    </button>
  )
}

export function SaveDraftButton() {
  const setState = useSetJobDraftState()
  const getSnapshot = useJobDraftSnapshot()

  async function saveDraft() {
    const revert = setState((draft) => {
      draft.saving = true
      draft.error = undefined
    })

    try {
      await api.saveDraft(getSnapshot((state) => state.draft))

      setState((draft) => {
        draft.saving = false
      })
    } catch {
      revert()

      setState((draft) => {
        draft.error = "Could not save the draft."
      })
    }
  }

  return <button onClick={saveDraft}>Save</button>
}

The short version:

  1. Define a state type.
  2. Create a Provider and typed hooks with createContextState<State>().
  3. Wrap the smallest subtree that needs the shared state.
  4. Read with selectors.
  5. Write with Immer draft updates.
  6. Use snapshots for async reads and rollback for optimistic updates.

API map

The factory returns a small set of hooks. In real code, I rename them per feature so consumers import domain-specific hooks instead of generic store primitives.

APIUse it when
ProviderA subtree needs access to this state boundary.
useContextState(selector)A component needs selected state, the setter, and sometimes a snapshot getter.
useContextStateValue(selector)A component only needs to render selected state.
useSetContextState(enableRevert?)A component only writes state and should not subscribe.
useStateSnapshotGetter()An event handler or async callback needs the latest state without re-rendering.
withContextProvider(component, config)A component should carry its own provider and map props into initial state.
useSetPostFlush()Expensive derived work should run once after a batch of mutations.

What problem it solves

React Context is good at dependency injection. It is less good as a high-frequency state store if every update changes the Provider value and wakes up every consumer.

createContextState keeps Context in the job it is best at: finding the nearest store in the React tree.

The reactive state itself lives in an external store owned by the Provider. Components subscribe to selected slices through useSyncExternalStore.

React tree
  |
  +-- Context Provider
        |
        +-- stable store API
              |
              +-- state ref
              +-- subscribe(callback)
              +-- setState(updater)
              +-- getSnapshot(selector)
              +-- post-flush callbacks

Components do not subscribe to "the whole context value".
They subscribe to selected state snapshots.

That difference is the main point. The Context value stays stable. State updates notify subscribers. Each subscriber decides which piece of state matters.

Where it fits

I do not use this as one giant global store. The useful pattern is a small number of explicit state boundaries.

Application
  |
  +-- App shell state
  |     language, viewer, selected company, permissions, layout flags
  |
  +-- Page state
  |     filters, active tab, selected rows, draft form values
  |
  +-- Feature state
        builder blocks, campaign editor, wizard progress, preview settings

A large product usually needs all three.

App shell state is for things that should survive normal page flow: language, account context, company context, feature access, or cross-page UI state.

Page state is for one route or workflow: filters, a table selection, an editor step, a compare drawer, a right-side preview, or a modal that several components need to control.

Feature state is for complex modules: a builder, a campaign editor, a multi-step form, a drag-and-drop surface, or an advanced search experience.

The rule is simple: put the Provider at the ownership boundary. If only one page needs the state, do not make it app-global. If multiple pages need a stable runtime value, put it in the app shell.

Navigos Talent One is the kind of product where this pattern pays for itself. It has many modules that need to feel like one continuous application: job posting, candidate search, application tracking, campaign tools, landing-page editing, account context, permissions, and multilingual UI.

The state-management problem was not “how do we store a counter?” It was:

  • How does a language switch update visible text immediately without reloading the app?
  • How do long editing flows keep local progress while users move through client-side pages?
  • How do search filters, tables, bulk actions, and drawers share page context without prop drilling?
  • How do feature teams own state boundaries without coupling every module to one global store?
  • How do high-frequency editor updates avoid re-rendering the whole page?
  • How do async saves read the latest state instead of a stale closure?
  • How do optimistic updates roll back when a request fails?

createContextState works because those problems are all variations of the same shape: scoped shared state with precise subscriptions.

Use case 1: page context sharing

Page context is the most common use case.

Imagine a search page with a filter bar, result table, selected-row toolbar, side drawer, and export button. Those components are siblings, but they need shared state:

  • Current filters.
  • Sort order.
  • Selected rows.
  • Drawer status.
  • Last loaded query.
  • Optimistic row changes.

You can push all of that into the URL, pass it through props, or put it in a global store. Each option has a cost.

A page-scoped Provider keeps the state close to the workflow:

SearchPageStateProvider
  |
  +-- FilterBar       reads filters, writes filters
  +-- ResultTable     reads rows + selection, writes selection
  +-- BulkToolbar     reads selected ids, writes optimistic actions
  +-- DetailDrawer    reads active item id, writes drawer state
  +-- ExportButton    reads a snapshot of filters when clicked

The page has one shared context, but each component subscribes to only the values it needs.

const selectedCount = useSearchPageValue(
  (state) => state.selectedIds.length
)

const setSearchPage = useSetSearchPageState()

The toolbar does not re-render because a filter input changes. The filter bar does not re-render because the drawer opens. The export button can read the latest filters at click time without staying subscribed to every filter change.

Use case 2: instant translation without reloads

In Navigos Talent One, language is part of app shell state. That means language is not treated as a one-time boot value. It is a runtime value that components can subscribe to.

The pattern is:

AppRuntimeStateProvider
  |
  +-- language: "en" | "vi"
  |
  +-- useLanguage()
  |     reads language
  |     writes language
  |
  +-- useTranslator(dictionary)
        subscribes to language
        returns a language-specific t() function

In simplified public code, it looks like this:

type AppRuntimeState = {
  language: "en" | "vi"
  selectedCompanyId?: string
}

const {
  Provider: AppRuntimeStateProvider,
  useContextStateValue: useAppRuntimeValue,
  useSetContextState: useSetAppRuntimeState,
} = createContextState<AppRuntimeState>("AppRuntime")

export function useLanguage() {
  const language = useAppRuntimeValue((state) => state.language)
  const setState = useSetAppRuntimeState(false)

  return {
    language,
    setLanguage(nextLanguage: AppRuntimeState["language"]) {
      setState((draft) => {
        draft.language = nextLanguage
      })
    },
  }
}

export function useTranslator(dictionary: TranslationDictionary) {
  const { language } = useLanguage()

  return React.useMemo(() => {
    return createTranslateFunction(dictionary, language)
  }, [dictionary, language])
}

When the user switches language, every translator hook that selected language gets a new value. Labels change immediately. The page does not need a full reload, and feature state such as form progress, open drawers, selected rows, or preview settings can stay alive.

This is a good example of why app shell state and page state should be separate. Language belongs to the app shell. The open state of a campaign editor drawer belongs to the page or feature.

Use case 3: non-reload flows across pages

Modern product UI often has flows that cross route boundaries:

  • A user changes language and follows a link.
  • A user edits filters, opens a detail page, and comes back.
  • A user changes selected company or account context.
  • A wizard uses URL query params for the active step.
  • A search page keeps state while the URL updates.

The state layer supports this by combining two ideas.

First, app shell state sits above route content, so client-side navigation does not destroy runtime values such as language, selected company, account context, or shared layout flags.

Second, URL-backed state can be mirrored into an external store with the same subscription model. Components can subscribe to specific query keys instead of re-rendering whenever any query param changes.

AppRuntimeStateProvider
  |
  +-- QueryParamSync
  |     URL search params -> query store
  |
  +-- Route content
        |
        +-- PageStateProvider
              page-only workflow state

The practical result is a smoother product flow:

  • Generated links can use the current language.
  • Controls can update query params without forcing a full page reload.
  • A page can read fresh query values during navigation.
  • App shell state survives normal client-side route changes.
  • Feature state can be intentionally reset or preserved by placing its Provider at the right boundary.

The important part is not the router wrapper. The important part is that route state, app state, and page state each have a clear owner.

Use case 4: optimistic updates

Many product actions should feel instant:

  • Toggle a candidate status.
  • Rename an item.
  • Reorder blocks in a builder.
  • Save a campaign setting.
  • Add or remove an item from a selection.

The default setter can return a revert function. That makes optimistic UI straightforward:

async function archiveCandidate(id: string) {
  const revert = setCandidatesState((draft) => {
    const candidate = draft.itemsById[id]
    candidate.archived = true
  })

  try {
    await api.archiveCandidate(id)
  } catch {
    revert()
  }
}

Under the hood, the rollback-capable path uses Immer patches. The update produces a new immutable state and stores enough inverse information to restore the previous value if the request fails.

For very hot update paths, such as drag-and-drop builder edits, rollback can be disabled:

const setBuilderState = useSetBuilderState(false)

That fast mode still gives you Immer draft ergonomics, but skips patch generation.

Use case 5: re-render optimization

The re-render model is the main reason this utility exists.

With plain Context state, a Provider value change often wakes every consumer below it. That is fine for small values. It becomes expensive when a page has dozens of components reading different pieces of one workflow state.

With createContextState, each component reads through a selector:

const title = useEditorValue((state) => state.draft.title)
const canPublish = useEditorValue((state) => state.validation.canPublish)
const selectedBlockId = useEditorValue((state) => state.selection.blockId)

If only state.selection.blockId changes, the title input does not need to re-render.

Selectors should be deterministic. They should derive values from state only, without time, randomness, network calls, or mutation.

// Bad: the value can change even when state did not.
const value = useEditorValue(() => Date.now())

// Good: the value is derived from state.
const value = useEditorValue((state) => state.draft.skills.length)

Setter-only components can avoid subscriptions entirely:

function PublishShortcut() {
  const setEditorState = useSetEditorState()

  React.useEffect(() => {
    function onKeyDown(event: KeyboardEvent) {
      if (event.metaKey && event.key === "Enter") {
        setEditorState((draft) => {
          draft.publishRequested = true
        })
      }
    }

    window.addEventListener("keydown", onKeyDown)
    return () => window.removeEventListener("keydown", onKeyDown)
  }, [setEditorState])

  return null
}

That component can write state without re-rendering every time the editor changes.

Use case 6: latest snapshots for async work

React closures are easy to make stale.

An async save handler might start with one version of state, wait for a validation request, and then need the latest draft after the user continued editing. A snapshot getter solves that without subscribing the component to the whole state object.

function SaveButton() {
  const getEditorSnapshot = useEditorSnapshot()

  async function save() {
    await api.validate()

    const latestDraft = getEditorSnapshot((state) => state.draft)
    await api.save(latestDraft)
  }

  return <button onClick={save}>Save</button>
}

Snapshot reads are useful for:

  • Async submit handlers.
  • Keyboard shortcuts.
  • Debounced autosave.
  • Analytics events.
  • Imperative builder commands.
  • Router guards.
  • Background sync.

They are deliberately non-reactive. Use a selector hook when UI should re-render. Use a snapshot getter when an event needs the latest value now.

Use case 7: large editors and derived state

Builders and visual editors often have one more problem: one user action can trigger several state mutations, and derived data can be expensive.

For example, a block builder may need to update:

  • The block map.
  • The selected block.
  • Parent-child relationships.
  • Validation state.
  • Preview metadata.

You do not want to recompute the full derived tree after every small mutation in the same event. A post-flush hook lets the state layer collect mutations and run expensive derived work once after the batch.

User action
  |
  +-- mutate block A
  +-- mutate selection
  +-- mutate preview flags
  |
  +-- microtask flush
        |
        +-- notify subscribers
        +-- recompute derived tree once

This is not needed for every page. It is useful when a feature has high-frequency updates and expensive derived state.

How createContextState works under the hood

The implementation is small because it combines a few focused primitives.

1. createContext creates the store boundary

The factory creates one React Context for one state shape.

function createContextState<State>(name: string) {
  const StoreContext = React.createContext<Store<State> | null>(null)

  function Provider({ initialState, children }: ProviderProps<State>) {
    const store = useContextStateStore(name, initialState)

    return (
      <StoreContext.Provider value={store}>
        {children}
      </StoreContext.Provider>
    )
  }

  return {
    Provider,
    useContextStateValue: createValueHook(StoreContext),
    useSetContextState: createSetterHook(StoreContext),
    useStateSnapshotGetter: createSnapshotHook(StoreContext),
  }
}

The important detail: Context does not hold the changing state value. It holds the store API.

That means useContext(StoreContext) answers “which store instance is nearest to me?” The actual reactivity comes from useSyncExternalStore.

2. The Provider owns a ref-based store

The Provider creates an external store around a state ref and a subscriber set.

type Store<State> = {
  getState: () => State
  subscribe: (callback: () => void) => () => void
  setState: (updater: StateUpdater<State>) => () => void
}

Conceptually:

function createStore<State>(initialState: State): Store<State> {
  let state = initialState
  const subscribers = new Set<() => void>()

  return {
    getState() {
      return state
    },

    subscribe(callback) {
      subscribers.add(callback)
      return () => subscribers.delete(callback)
    },

    setState(updater) {
      state = applyUpdateWithImmer(state, updater)
      queueMicrotask(() => {
        subscribers.forEach((callback) => callback())
      })

      return () => {
        state = rollbackPreviousUpdate(state)
        subscribers.forEach((callback) => callback())
      }
    },
  }
}

The real implementation has more guardrails, but the shape is this: the store owns current state, subscriptions, mutation, notification, snapshots, and rollback.

3. Selectors use useSyncExternalStore

A value hook subscribes to the store and runs a selector against the latest state.

function useContextStateValue<State, Selected>(
  StoreContext: React.Context<Store<State> | null>,
  selector: (state: Readonly<State>) => Selected
) {
  const store = React.useContext(StoreContext)

  if (!store) {
    throw new Error("Missing createContextState Provider")
  }

  return React.useSyncExternalStore(
    store.subscribe,
    () => selector(store.getState()),
    () => selector(store.getState())
  )
}

useSyncExternalStore gives React a formal way to subscribe to external mutable state. It is also the right primitive for concurrent rendering because React controls when snapshots are read.

In the full implementation, selector results are cached. If a selector returns a structurally equal object, the hook can keep the previous reference so React does not re-render needlessly.

4. Updates use Immer

The setter accepts draft updates:

setState((draft) => {
  draft.filters.keyword = "designer"
  draft.pagination.page = 1
})

That is easier to read than:

setState((state) => ({
  ...state,
  filters: {
    ...state.filters,
    keyword: "designer",
  },
  pagination: {
    ...state.pagination,
    page: 1,
  },
}))

Immer produces the immutable next state. When rollback is enabled, Immer patches make it possible to return a revert() function from the setter.

5. Notifications are batched

State can change several times in one event. The store can queue subscriber notification into a microtask so multiple updates flush together.

setState()
setState()
setState()
  |
  +-- one queued flush
        |
        +-- notify subscribers
        +-- run post-flush work

That keeps UI responsive and prevents expensive derived work from running too often.

6. Snapshot getters avoid stale closures

A snapshot getter is just a stable function that reads the latest state from the store.

const getSnapshot = useStateSnapshotGetter()

const latestTitle = getSnapshot((state) => state.draft.title)

It does not subscribe. It does not cause renders. It is for event-time reads.

Why not Redux, Zustand, or Jotai?

Those are all good tools. createContextState is narrower.

Redux is strong when you want a central event log, devtools, middleware, and a consistent application-wide data flow.

Zustand is strong when you want a minimal external store with a flexible API.

Jotai is strong when atom composition is the natural model.

createContextState is useful when ownership should follow the React tree:

  • One app shell store for runtime app context.
  • One page store for a route workflow.
  • One feature store for a complex module.
  • No central registry of every feature’s state.
  • No prop drilling through layout components.
  • No mass re-renders from plain Context values.

It is not a replacement for server cache, and it is not a reason to move every useState into a shared store.

When I reach for it

I reach for createContextState when at least two of these are true:

  • State is shared by several sibling components.
  • The owner is a page, feature, or app shell boundary.
  • Components need different slices of the same state.
  • Event handlers need latest snapshots.
  • Optimistic UI needs rollback.
  • Draft updates are deeply nested.
  • A workflow should survive client-side navigation.
  • A large editor needs fast updates and controlled derived recomputation.

I avoid it when:

  • One component owns the state.
  • The data is server cache.
  • The state is better represented in the URL.
  • The feature only needs a simple Context value that changes rarely.
  • A team already benefits from Redux or Zustand devtools for that surface.

The goal is not to make state clever. The goal is to make ownership obvious.

Public source

The public implementation lives here:

The product case study is here:

The pattern is small, but it has held up because it matches how complex product UI is actually organized: global where it is truly global, page-scoped where a page owns the workflow, feature-scoped where a module needs its own state, and selector-driven everywhere re-render cost matters.