Browse documentation
Integrations / Redux & Redux Toolkit integration

Redux & Redux Toolkit integration

Keep your Redux store and share selected immutable collections with workers. Connect zerocopy to Redux Toolkit, selectors, and DevTools.

Source documentationView source ↗
On this page

Documentation · Compatibility notes · Selectors and checkpoints · Worker sharing

zerocopy/redux integrates immutable collection handles with Redux Toolkit. It provides middleware options, selectors, DevTools configuration, a portable value codec, and trusted binary checkpoints. The adapter has no runtime dependency on Redux, React, or Immer.

Configure a store#

Immer can update the plain object that contains a collection. It does not draft the collection's internals. Keep returned snapshots, do not assign collection fields, and do not add Immer's immerable marker to the classes.

ts
import { configureStore, createSlice, type PayloadAction } from '@reduxjs/toolkit';
import { SharedMap, compact } from 'zerocopy';
import {
  zerocopyMiddlewareOptions,
  createZerocopyDevToolsOptions,
  setSharedMapValue,
} from 'zerocopy/redux';

const catalogSlice = createSlice({
  name: 'catalog',
  initialState: () => ({
    stock: compact(new SharedMap('number')),
    selectedId: null as string | null,
  }),
  reducers: {
    stockChanged(state, action: PayloadAction<{ id: string; quantity: number }>) {
      state.stock = setSharedMapValue(
        state.stock,
        action.payload.id,
        action.payload.quantity,
      );
    },
    selectionChanged(state, action: PayloadAction<string | null>) {
      state.selectedId = action.payload;
    },
  },
});

export const store = configureStore({
  reducer: { catalog: catalogSlice.reducer },
  middleware: getDefaultMiddleware => getDefaultMiddleware(zerocopyMiddlewareOptions),
  devTools: createZerocopyDevToolsOptions(),
});
export type RootState = ReturnType<typeof store.getState>;

The initial compact() gives the store a separate arena lifetime. It allocates a new arena. Ordinary updates are also valid:

ts
state.stock = state.stock.set(id, quantity);

setSharedMapValue() is an optional no-op guard. It keeps the same map when an existing value passes Object.is. For object values, supply a pure domain comparison when equal content should count as a no-op. The helper works with Immer's mapped Draft type without an application cast. The core .set() method has no general equal-content identity guarantee.

Keep selections, panel settings, and request status in plain state. Use shared collections for data that benefits from snapshots and worker access.

Middleware behavior#

zerocopyMiddlewareOptions changes the serializability check's predicates and keeps the other default Toolkit middleware. Its immutability predicate follows Toolkit's primitive/frozen-object policy. zerocopySerializableCheck and zerocopyImmutableCheck are also available separately. Unsupported Dates, functions, promises, and other values outside collections are still reported. A global serializableCheck: false or blanket ignored state path is not required.

A supported frozen collection is treated as an atomic value. The check does not iterate entries, decode all values, or inspect WASM memory on dispatch. This is constant work per handle, not per entry, and relies on the immutable API contract.

isSharedCollection() recognizes the 12 built-in classes with their private arena brand. Prototype-only fakes, arbitrary subclasses, and root descriptors are not accepted. The serializability check also rejects collections with non-serializable custom comparators. Primitive and object payloads retain the collection codec's rules.

Passing a middleware check does not make JSON.stringify() save collection values. A root, size, and type cannot restore a collection after reload. Use the portable codec below for that purpose.

Select values without unstable decoded object references#

Selecting a collection handle works with reference comparison:

ts
const stock = useSelector((state: RootState) => state.catalog.stock);

Use a selector instance for an individual map entry:

ts
import { createSharedMapValueSelector } from 'zerocopy/redux';

export const selectCurrentStock = createSharedMapValueSelector(
  (state: RootState) => state.catalog.stock,
  state => state.catalog.selectedId ?? '',
);

Object decoding uses a bounded cache. A raw .get() outside that cache can return a different frozen object reference. This helper retains one result by arena identity, value type, and immutable leaf identity, so unrelated map updates can reuse the selected value.

Use one selector per component or fixed selection. For an ID prop, createSharedMapEntrySelector() accepts additional arguments while keeping the same one-result cache:

ts
import { createSharedMapEntrySelector } from 'zerocopy/redux';

const selectStockById = createSharedMapEntrySelector(
  (state: RootState) => state.catalog.stock,
  (_state: RootState, id: string) => id,
);
const quantity = selectStockById(store.getState(), 'product-123');

Do not create a selector on every render or share one single-result cache across many row IDs. Replacing the selected entry or compacting the arena can produce a new reference even when contents are equal. Argument-aware declarations require TypeScript 5.4 or newer.

A selector retains its last arena and value. Release unused selector instances when a workspace closes. All selector reads are local and synchronous.

DevTools: summaries or portable values#

createZerocopyDevToolsOptions() defaults to mode: 'summary' and maxAge: 50. The monitor receives collection type, size, and value type, not every entity. The sanitizer does not execute ordinary getters. Actual Redux state remains unchanged. In-page history can still read retained snapshots. Summary output is for display, not backup or rehydration.

Summary mode explicitly enables pause, lock, jump, skip, reorder, and dispatch. Import, export, persistence, and generated tests are disabled because summaries cannot reconstruct state. The history cursor does not automatically move to a new action while an older state is selected. Normal DevTools controls apply; COMMIT makes the selected state the new history base.

For value export and restore:

ts
const devTools = createZerocopyDevToolsOptions({ mode: 'portable', maxAge: 30 });

Portable mode provides JSAN replacer, reviver, and serialization options together. Keep those options intact; do not replace serialize.options with true. Restored collections are writable and do not depend on an old pointer registry. Literal $jsan, $zerocopyRedux, and $zerocopyReduxText fields round-trip through an escaped transport. Read the compatibility notes for details.

Portable mode walks values and allocates encoded JavaScript data. It is not zero-copy and can be expensive for large maps or long histories. Use summaries for routine large-map development and export values when needed. maxAge limits retained actions, not arena bytes.

createZerocopyDevTools aliases createZerocopyDevToolsOptions. sanitizeZerocopyState and summarizeZerocopyState expose the same display-only operation.

Save and restore application state#

ts
import { createZerocopyCodec } from 'zerocopy/redux';

const codec = createZerocopyCodec();
const saved = codec.stringify(store.getState());
// Save the text using the application's storage system.
const restored: unknown = codec.parse(saved);
// Validate the application schema before using restored as preloadedState.

Use encode() and decode() for structured data rather than text. Parsing returns unknown: collection validation is not validation of domain fields. Handle parse and schema errors before creating the store. Storage libraries such as redux-persist need an explicit transform or codec hook.

The codec supports all 12 classes, nested collections, ordinary records, arrays, and primitives. It preserves undefined fields, array holes, null-prototype records, NaN, infinities, and negative zero in ordinary state and compatible primitive collections. Object-typed collections still store JSON.

It preserves stack/queue order, ordered-map/set insertion order, priority-queue direction, and the existing equal-priority dequeue behavior. Portable heap child indexes are validated. Restore recomputes ranks and sizes, then writes fresh unpublished nodes; it does not treat input as a WASM pointer. Unordered map/set iteration is not a persistence contract.

Repeated references are encoded by value. Object aliases, wrapper identity, cross-version structural sharing, and memory addresses are not retained. Cycles, functions, custom comparators, arbitrary class instances, enumerable accessors, enumerable symbol keys, and custom array properties are rejected. These limits apply even when a less strict middleware configuration accepts such values.

Default codec limits are 128 nesting levels, 1,000,000 visited values, 1,000,000 items per collection/record, and 64 Mi UTF-16 code units of text. They are validation limits, not a small-memory guarantee. Encoding creates output before the final text-length check. Use lower application limits for untrusted imports. Failed imports do not reset arenas or invalidate existing Redux state.

For trusted, same-binary-format snapshots, encodeZerocopyState() and serializeZerocopyState() provide complete compacted binary checkpoints. These are separate from the logical codec and need temporary copies. Their envelope checks are not a hostile WASM graph validator. Read Selectors and checkpoints before using them.

Workers and ownership#

Send snapshots outside reducers, for example in listener middleware:

ts
import { getWorkerData } from 'zerocopy';

worker.postMessage(getWorkerData({ stock: store.getState().catalog.stock }));

Do not put raw WebAssembly.Memory, SharedArrayBuffer, or worker objects in Redux actions. Attach snapshots with initWorker(). Include an application revision or request ID and discard stale results.

An attached snapshot is suitable for read-only UI state, but reducers cannot allocate updates in its arena. Use compact() for an independent writable copy. Worker sharing does not make reducers asynchronous or remove catalog-filtering costs. Browser headers and runtime differences are covered in Worker sharing. The bindRedux() and reduxSource() session helpers remain exported from zerocopy/redux.

Memory and long editing sessions#

Snapshots, workers, payloads, selectors, nested dependencies, and DevTools history can retain arenas. Trimming history does not reclaim dead nodes while the current snapshot still uses the same append-only arena.

Compact live state at a controlled boundary, keep the result, and release old holders. A module-default arena stays referenced until its reset function selects a new one. Reset does not compact current state. Avoid compaction on every dispatch and measure its copy cost separately. See Architecture and memory.

Tests and upstream contracts#

The contributor commands build the package, run core and Redux type checks, and execute integration tests. redux.test.ts covers Toolkit, Immer, old states, codecs, workers, and selectors. redux-regression.test.ts covers codec limits, imports, heap restore, and JSAN. The additional checkpoint, JSAN, and Toolkit suites run with bun run test:redux. Browser tests cover Chromium, module workers, and React-Redux rendering.

These checks use the DevTools instrument and JSAN libraries, not the installed browser extension UI. The public-consumer type checks use generated declarations. There is no separate measured Redux performance claim.

Relevant upstream contracts: Redux state organization, Toolkit serializability middleware, Immer classes, React-Redux selectors, and DevTools instrument.

Found something unclear?Improve this guide ↗

Type a topic to find a guide.

↓ ↑ to move · Enter to open · Esc to close

Browse documentation →