createContextState: shared React state scoped to a page or feature
How I use createContextState from @khanglvm/react for shared UI state in Navigos Talent One, with examples of selectors, Immer updates, snapshots, and rollback.
Server data usually belongs in a cache such as TanStack Query. For UI state owned by one component, I keep useState close to that component. The harder case is client state shared across a page or feature: an editing flow, for example, where several components read and update different parts of the same draft.
I built createContextState while working on Navigos Talent One. Its recruitment modules include long editing flows, campaign tools, and search pages, with language switching across the application. Those workflows needed shared state with an explicit owner.
The factory combines React Context, useSyncExternalStore, and Immer. Given a state type, it returns a Provider and hooks for selected reads, updates, and the latest snapshots. Setters can also return a function to roll back an optimistic update.
The public version lives in khanglvm/react. The examples below use simplified public code, keeping product code out of the post.
createContextState is one part of @khanglvm/react. The package also has typed translations, browser events, and helpers for component props and children. It supports React 18 and 19 and ships ES modules with TypeScript declarations.
npm install @khanglvm/react
See the state guide for the API and its limits.
Quick usage
A page or feature store provides:
- A Provider around the part of the tree that owns the state.
- Selector-based subscriptions so components re-render only when their selected value changes.
- Immer draft updates for nested state.
- A latest-state snapshot getter for async callbacks.
- An optional revert function for optimistic UI.
import { createContextState } from "@khanglvm/react"
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>
}
To adapt the example to a feature:
- Define a state type.
- Create a Provider and typed hooks with
createContextState<State>(). - Wrap the smallest subtree that needs the shared state.
- Read with selectors.
- Write with Immer draft updates.
- Use snapshots for async reads and rollback for optimistic updates.
API map
I rename the returned hooks for each feature, so a consumer imports a hook that names the state it works with. These are the original API names:
| API | Use it when |
|---|---|
Provider |
A 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
When a Context Provider's value changes, it notifies every consumer of that Context. Frequent updates become expensive if those consumers only need different small pieces of the value.
createContextState uses Context to find the nearest store in the tree. The Provider owns that external store, and 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.
The Context value stays stable while state changes inside the store. An update notifies subscribers, whose selectors determine whether their selected value changed.
Where it fits
I place stores at the app shell, page, or feature that owns their state:
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 scopes. Values such as language, account and company context, feature access, and UI state used across pages belong in the app shell so they survive navigation.
A page store holds state for one route or workflow. That might include a table selection, filters, an editor step, a compare drawer, a preview on the right, or a modal controlled by several components. A complex feature can own a separate store for its builder, campaign editor, multi-step form, drag-and-drop surface, or advanced search.
Place the Provider where that state is owned. A value used on one page can stay there; a runtime value shared across pages belongs above them in the app shell.
Requirements from Navigos Talent One
Navigos Talent One combines job posting, candidate search, application tracking, campaign tools, and landing-page editing. Those modules share account context, permissions, and a multilingual UI. That left several state questions to solve:
- 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?
These requirements led to stores with scoped ownership and subscriptions to individual values.
Use case 1: page context sharing
The most common case is a page whose sibling components share state. On a search page, the filter bar, result table, selected-row toolbar, side drawer, and export button may need access to:
- Current filters.
- Sort order.
- Selected rows.
- Drawer status.
- Last loaded query.
- Optimistic row changes.
URLs, props, and global stores are other places to put this state, each with its own costs. A page-scoped Provider gives these components a shared store for their 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()
Changing a filter input does not need to re-render the toolbar, and opening the drawer does not need to re-render the filter bar. The export button can read the latest filters when clicked without subscribing to changes as the user types.
Use case 2: instant translation without reloads
In Navigos Talent One, components subscribe to language as a runtime value in app shell state:
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])
}
Switching language updates every translator hook that selected language, so labels change without a full reload. Form progress, open drawers, selected rows, and preview settings can stay in place.
Language belongs to the app shell because it affects the whole application. A campaign editor's drawer state stays with its page or feature.
Use case 3: keeping state across navigation
A workflow may cross route boundaries in several ways:
- 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.
App shell state sits above route content. Client-side navigation can therefore preserve the language, selected company, account context, and shared layout flags.
For URL-backed state, a separate external store can mirror query parameters using the same subscription model. A component can subscribe to the query keys it uses and avoid updates caused by unrelated parameters.
AppRuntimeStateProvider
|
+-- QueryParamSync
| URL search params -> query store
|
+-- Route content
|
+-- PageStateProvider
page-only workflow state
With these stores in place:
- 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.
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 to call if the request fails:
async function archiveCandidate(id: string) {
const revert = setCandidatesState((draft) => {
const candidate = draft.itemsById[id]
candidate.archived = true
})
try {
await api.archiveCandidate(id)
} catch {
revert()
}
}
When rollback is enabled, an update uses Immer patches to produce the next immutable state and retain the inverse changes needed to restore the previous value.
For frequent updates such as drag-and-drop builder edits, you can disable rollback:
const setBuilderState = useSetBuilderState(false)
This keeps Immer draft updates and skips patch generation.
Use case 5: re-render optimization
A page can have dozens of components reading different pieces of one workflow's state. Updating all of them through a changing Context value is often unnecessary. Each component using createContextState 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.
Derive selector results only from state. Time, randomness, network calls, or mutations can make the result change independently of the state being read.
// 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
An async save handler may wait for validation while the user continues editing. By the time validation finishes, the handler's closure can hold an old draft. A snapshot getter reads the latest draft at that point, 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.
Snapshot reads do not subscribe to updates. Use a selector hook to keep rendered UI current, and a snapshot getter to read the latest value inside an event handler.
Use case 7: large editors and derived state
One action in a visual editor can trigger several mutations. A block builder, for example, may need to update:
- The block map.
- The selected block.
- Parent-child relationships.
- Validation state.
- Preview metadata.
Recomputing the full derived tree after each mutation would repeat expensive work within the same event. A post-flush hook collects those mutations and runs the derived work once after the batch.
User action
|
+-- mutate block A
+-- mutate selection
+-- mutate preview flags
|
+-- microtask flush
|
+-- notify subscribers
+-- recompute derived tree once
Consider this hook when frequent updates make derived-state computation expensive.
How createContextState works
The implementation separates store ownership from subscriptions and updates.
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),
}
}
Context holds the stable store API, so useContext(StoreContext) finds the nearest store instance. useSyncExternalStore handles the subscription to changing state.
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())
}
},
}
}
This sketch omits checks from the full implementation. The store owns the current state and manages 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 lets React subscribe to the external state and control when it reads snapshots during concurrent rendering.
In the full implementation, selector results are cached on both the client and server. If a selector returns a structurally equal object, the hook can keep the previous reference so React does not re-render needlessly. Each Provider initializes its own store; sharing a factory does not share state across Providers or server requests. Server and client initial values still need to match for hydration.
4. Updates use Immer
The setter accepts draft updates:
setState((draft) => {
draft.filters.keyword = "designer"
draft.pagination.page = 1
})
The equivalent update with object spreading is longer:
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
Batching limits repeated notification and derived work, helping the UI stay responsive.
6. Snapshot getters avoid stale closures
A snapshot getter is a stable function that reads the latest state from the store.
const getSnapshot = useStateSnapshotGetter()
const latestTitle = getSnapshot((state) => state.draft.title)
The getter reads state at event time without subscribing or causing renders.
Choosing a state model
Redux supports a central event log, devtools, middleware, and a consistent application-wide data flow. Zustand has a minimal external store with a flexible API; Jotai organizes state through atom composition.
I use createContextState for stores whose ownership follows 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.
Server data can stay in its cache, and component-owned state can stay in useState.
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.
Package and source
Install @khanglvm/react from npm. The implementation and utility guides are in github.com/khanglvm/react. The Navigos Talent One case study covers the product where I developed the pattern.