Skip to main content
May 22, 2026 8 min read

createContextState: Scoped React State Without the Re-render Tax

A practical guide to createContextState, a React state helper for scoped stores, selector subscriptions, snapshots, and optimistic rollback.

React State Management Architecture Open Source

Most React state should stay boring. Keep it in useState when one component owns it. Put it in the URL when the browser should remember it. Use a server cache when the data belongs to the backend.

createContextState is for the awkward middle: one page or feature owns a workflow, several sibling components need different parts of it, and plain React Context would wake too much UI on every change. I wrote it for product screens where filters, drawers, selection, language, drafts, and async actions all meet in the same route.

The public implementation lives in react-utility. The pattern came out of work on Navigos Talent One, where the UI had to keep recruiter workflows responsive while state moved across route changes, side panels, forms, and language switches.

What createContextState gives you

createContextState creates a scoped store that follows the React tree. The Provider decides ownership. The hooks decide how each component reads or writes.

type SearchPageState = {
  filters: {
    keyword: string
    status: string[]
  }
  selectedIds: string[]
  activeCandidateId?: string
}

const {
  Provider: SearchPageStateProvider,
  useContextStateValue: useSearchPageValue,
  useSetContextState: useSetSearchPageState,
  useStateSnapshotGetter: useSearchPageSnapshot,
} = createContextState<SearchPageState>("SearchPage")

That gives a feature four practical tools:

  • A Provider that owns one state object.
  • A selector hook for reactive reads.
  • A setter hook for Immer-style draft updates.
  • A snapshot getter for event-time reads without subscribing.

The Provider does not pass the changing state through Context. It passes a stable store API. React Context answers one question: which store is nearest to this component? React reactivity comes from useSyncExternalStore.

The problem: page state that is wider than one component

A search page is a normal example. It often has filters, a result table, selected rows, a detail drawer, export actions, and maybe a bulk toolbar.

SearchPageStateProvider
  |
  +-- FilterBar       reads filters, writes filters
  +-- ResultTable     reads rows and selection
  +-- BulkToolbar     reads selected ids, writes optimistic actions
  +-- DetailDrawer    reads the active item id
  +-- ExportButton    reads a fresh filter snapshot when clicked

You can push that state into the URL, route it through props, or put it in a global store. Each choice has a cost. A page-scoped store keeps the owner close to the workflow and still lets components subscribe to only the pieces they use.

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

const setSearchPage = useSetSearchPageState()

The toolbar does not need to re-render because someone typed in a filter box. The filter bar does not need to re-render because a drawer opened. The export button can read the latest filters at click time without staying subscribed to the full filter object.

That is the main reason this helper exists. Plain Context is fine when the value changes rarely. It becomes noisy when a busy product page has many consumers under the same Provider.

Selectors keep re-renders local

Components read through selectors:

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 has no reason to render again. The component that reads canPublish can stay asleep too.

Selectors should be deterministic. They should derive a value from state only.

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

// Good: this comes from the current 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 shortcut can write to the editor state without re-rendering every time the editor changes.

Runtime app state belongs above the route

The same pattern works for app shell state, but the Provider should move up. In Navigos Talent One, language is runtime state. It is not a one-time boot value, because users can switch language while they are already inside a workflow.

In simplified public code:

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 changes language, hooks that selected language receive a new value. Labels update immediately. Form progress, open drawers, selected rows, and preview settings can stay alive because those values belong to their own feature or page store.

This boundary matters. Language belongs to the app shell. The open state of a campaign editor drawer belongs to the editor page. When ownership is clear, reset behavior becomes a placement decision instead of a bug hunt.

Route changes without losing runtime state

Product flows often cross route boundaries. A user changes language, opens a detail page, comes back to search, and expects the app to remember enough context to avoid starting over.

The pattern usually looks like this:

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

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

URL state can still belong in the URL. When query params drive the page, a small sync layer can mirror the parsed values into an external store. Components subscribe to the query values they use instead of re-rendering because any query string changed.

The important part is ownership. App state, route state, and page state should each have a clear place to live.

Optimistic updates with rollback

Product actions should often feel instant. A recruiter should not wait for the network before a row looks archived or a status toggle appears selected.

The setter can return a revert function:

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

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

The rollback path uses Immer patches. The update produces a new immutable state and keeps enough inverse information to restore the previous value if the request fails.

For hot paths, rollback can be disabled:

const setBuilderState = useSetBuilderState(false)

That mode still gives draft update ergonomics, but it skips patch generation. I use it for interactions such as drag edits where the app already has a separate save or undo model.

Snapshot getters avoid stale closures

React closures are easy to get wrong in async handlers. A save action can start with one version of state, wait for validation, and then need the latest draft after the user kept typing.

A snapshot getter solves that without subscribing the button to the whole editor state:

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, router guards, and 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 right now.

Large editors need a flush point

Builders and visual editors can have expensive derived state. One user action might update the block map, selection, parent-child relationships, validation state, and 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 work once after the batch.

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

Most pages do not need this. It becomes useful when a feature has frequent updates and derived data that costs enough to notice.

How the store works

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

First, 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),
  }
}

Second, the Provider owns a ref-based store:

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

Third, selector hooks subscribe with useSyncExternalStore:

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())
  )
}

The real code has more guards and selector result caching, but that is the shape: Context finds the store, the store owns the current state, and React reads snapshots through the external-store contract.

Why not Redux, Zustand, or Jotai?

Those tools are good. createContextState is narrower.

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

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

Jotai works well when atom composition matches the problem.

createContextState is for state whose owner should follow the React tree: one app shell store for runtime context, one page store for a route workflow, or one feature store for a complex editor. It avoids a central registry while still fixing the re-render problem that plain Context introduces.

It is not a server cache. It is not a reason to move every useState into a shared store. If one component owns the value, keep the value in that component.

When I reach for it

I use createContextState when the state is shared by sibling components, the owner is a page or feature boundary, components need different slices, and event handlers need fresh snapshots.

I also reach for it when optimistic UI needs rollback or when nested draft updates would make immutable object spreads hard to read.

I avoid it when the data belongs to the server, when the URL should be the source of truth, or when an existing Redux or Zustand surface is already paying for itself.

The goal is plain: make ownership obvious, keep renders local, and let event handlers read the current state without fighting React closures.

Public source

The public implementation is in github.com/khanglvm/react-utility.

The product context behind the pattern is the Navigos Talent One case study.