Guides

Migrate from Zustand

Move an existing store incrementally while keeping selectors, middleware, and worker adoption deliberate.

Coaction deliberately keeps a familiar create() shape, but a migration is more than changing an import. Move one behavior at a time and keep tests green between steps.

1. Start locally

Do not begin by moving the store into a worker. First reproduce the current local behavior:

import { create } from '@coaction/react';

const useCounter = create((set) => ({
  count: 0,
  increment() {
    set((draft) => {
      draft.count += 1;
    });
  }
}));

Zustand updaters usually return a partial object. Coaction also accepts object merges, but its native path is a draft callback inside set().

2. Keep selectors first

Existing selector-oriented components can keep the explicit style:

const count = useCounter((state) => state.count);

The behavior is a version + selector recomputation + Object.is comparison, so it is a familiar and low-risk migration point.

Once the store is stable, use observer() where automatic read tracking removes meaningful selector boilerplate:

const Counter = observer(() => {
  const state = useCounter();
  return <button onClick={state.increment}>{state.count}</button>;
});

3. Move repeated derived state into getters

const useCart = create((set) => ({
  items: [] as Item[],
  get total() {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }
}));

Accessor getters are cached. Use get(deps, selector) only when explicit dependencies make the code clearer.

4. Decide whether namespaced slices help

Zustand's common slice pattern spreads factories into one flat object. Coaction native slices are namespaced:

const store = create({ counter, settings }, { sliceMode: 'slices' });

Do not convert solely to preserve the word “slice.” Keep a single store when the flat shape is clearer.

5. Replace middleware separately

Zustand concernCoaction path
Immer update middlewareBuilt-in draft updates through set()
Persist@coaction/persist on the owning store
Selector subscriptionsFramework selectors or automatic tracking
Existing external Zustand runtime@coaction/zustand whole-store adapter
Custom external runtimedefineExternalStoreAdapter() from coaction/adapter

Coaction does not currently promise a Redux DevTools-compatible built-in experience. Keep project-specific diagnostics or use logger until a maintained DevTools surface exists.

6. Keep Zustand underneath when that is safer

Use @coaction/zustand when the existing Zustand store is itself public API, other modules depend on it directly, or replacing ownership would create more risk than value. The adapter is a whole-store integration; it cannot live inside native Coaction slices.

7. Add shared mode last

After local tests pass, create the same source on a worker authority and connect a client:

const worker = new Worker(new URL('./worker.ts', import.meta.url), {
  type: 'module'
});

const store = create(source, { worker });
await store.getState().increment();

Audit the state against the strict JSON contract and change call sites to await actions. Direct client setState() is not a supported migration shortcut.

Checklist

  • Keep explicit selectors during the first behavior-preserving step.
  • Move writes into set() and keep the root schema stable.
  • Convert repeated derived selectors into cached getters selectively.
  • Choose namespaced slices only when they improve ownership.
  • Replace persistence/history/logging one at a time.
  • Keep the existing Zustand runtime through the adapter when appropriate.
  • Introduce workers only after local behavior and serialization are verified.

On this page