How to Use a Zustand Vanilla Store Without React for Non-UI State

Import createStore from zustand/vanilla to instantiate a framework-agnostic store that exposes getState, setState, subscribe, and getInitialState for use in Node.js, workers, or any non-UI context.

Zustand is not inherently tied to React. The pmndrs/zustand repository provides a vanilla API that lives in src/vanilla.ts, allowing you to use Zustand vanilla store without React for server-side logic, background workers, or Electron main processes. This guide explains the core implementation details and practical patterns for managing non-UI state.

What Is the Zustand Vanilla Store?

The vanilla store is the underlying engine that powers Zustand’s React integration. When you use createStore (lines L99-L107 in src/vanilla.ts), you receive a plain JavaScript object with a minimal CRUD API:

Method Description
setState Mutates the store. Shallow-merges objects by default; pass true as the second argument to replace the entire state.
getState Returns the current state snapshot (or the result of a selector).
getInitialState Returns the state produced by the initializer before any mutations.
subscribe Registers a listener (newState, previousState) => void. Returns an unsubscribe function.

Because there is no React hook involved, the store can be imported and used anywhere JavaScript runs.

Core API Methods in src/vanilla.ts

createStore Implementation

The createStore function (lines L60-L69) is a thin wrapper around createStoreImpl. It invokes your initializer with three utilities—setState, getState, and the store API—and stores the resulting state in a private variable (lines L63-L66). Listeners are tracked in a Set (lines L64-L65) for efficient subscription management.

setState and State Updates

The setState logic (lines L70-L80) handles both functional and object updates:

  • Functional updates: If you pass a function, it receives the current state and returns the next state.
  • Merge vs. Replace: By default, setState shallow-merges objects. If you pass true as the second argument (lines L75-L78), or if the new value is not an object, the state is replaced outright.
  • Equality check: Listeners are only notified if the new state differs from the previous one using Object.is (line L73).

Subscription Handling

The subscribe method (lines L88-L92) adds a listener to the internal Set. On every successful state update, the store iterates through listeners (line L79) and invokes them with (newState, previousState). The subscribe call returns a function that removes the listener when called (line L91).

getInitialState

The getInitialState method (lines L85-L87) caches the state produced by the initializer at creation time. This is useful for resetting or comparing against the original state after mutations have occurred.

Practical Examples: Using Zustand Without React

Basic Store Creation and Usage

Create a store in a standalone file and import it anywhere:

// store.ts
import { createStore } from 'zustand/vanilla'

export const counterStore = createStore<{ count: number; inc: () => void }>((set) => ({
  count: 0,
  inc: () => set((state) => ({ count: state.count + 1 })),
}))
// worker.ts (Node.js or Web Worker)
import { counterStore } from './store'

// Read state
console.log(counterStore.getState().count) // 0

// Mutate state
counterStore.getState().inc()
console.log(counterStore.getState().count) // 1

// Subscribe to changes
const unsubscribe = counterStore.subscribe((newState, prevState) => {
  console.log(`Changed from ${prevState.count} to ${newState.count}`)
})

counterStore.getState().inc() // Logs: Changed from 1 to 2
unsubscribe() // Stop listening

Replacing State vs. Merging

By default, setState shallow-merges objects. To replace the entire state object, pass true as the second argument:

import { createStore } from 'zustand/vanilla'

type State = { a?: number; b?: number }
const store = createStore<State>(() => ({ a: 1 }))

// Merge (default)
store.setState({ b: 2 })
console.log(store.getState()) // { a: 1, b: 2 }

// Replace
store.setState({ b: 2 }, true)
console.log(store.getState()) // { b: 2 } (a is gone)

This corresponds to the replace logic at lines L75-L78 in src/vanilla.ts.

Working with Primitive Values

The vanilla store supports primitive state values, not just objects:

import { createStore } from 'zustand/vanilla'

const numberStore = createStore<number>(() => 0)

// Functional update
numberStore.setState((n) => n + 5)
console.log(numberStore.getState()) // 5

// Replace with primitive
numberStore.setState(10, true)
console.log(numberStore.getState()) // 10

The setState implementation (lines L70-L72) handles both functional and direct value updates.

Accessing Initial State

Retrieve the original state for reset functionality or comparison:

import { createStore } from 'zustand/vanilla'

const initialState = { count: 0, name: 'default' }
const store = createStore<typeof initialState>(() => initialState)

// Mutate
store.setState({ count: 5, name: 'updated' })

// Access initial
console.log(store.getInitialState()) // { count: 0, name: 'default' }
console.log(store.getState())        // { count: 5, name: 'updated' }

This uses the getInitialState method defined at lines L85-L87 in src/vanilla.ts.

Optional React Integration

If you later need to use the same store in a React component, bind it with the useStore hook without recreating the logic:

// reactBinding.tsx
import { useStore } from 'zustand'
import { counterStore } from './store'

export const useCounter = <T,>(selector: (state: typeof counterStore.getState()) => T) => 
  useStore(counterStore, selector)

// Component usage
function Counter() {
  const count = useCounter((s) => s.count)
  const inc = useCounter((s) => s.inc)
  return <button onClick={inc}>Count: {count}</button>
}

The useStore hook in src/react.ts wraps the vanilla store's subscribe and getState methods for React integration.

File Structure and Source References

Understanding the source layout helps when debugging or extending the vanilla store:

File Purpose Link
src/vanilla.ts Core createStore implementation, state API, and subscription handling. view
src/vanilla/shallow.ts Shallow-equality helper used by selectors and middleware. view
src/react.ts useStore hook that binds vanilla stores to React components. view
src/middleware/* Optional middleware (e.g., persist, immer, devtools) compatible with vanilla stores. view
tests/vanilla/basic.test.ts Test suite demonstrating the vanilla API in practice. view

Summary

  • Import from zustand/vanilla: Use createStore instead of the default create to eliminate React dependencies and reduce bundle size.
  • Four core methods: Control state with getState, setState, subscribe, and getInitialState as implemented in src/vanilla.ts.
  • Flexible updates: setState shallow-merges objects by default; pass true as the second argument to replace the entire state (lines L75-L78).
  • Framework-agnostic: Use the same store in Node.js scripts, Web Workers, or Electron main processes without React.
  • React compatible: Bind an existing vanilla store to React later using the useStore hook from zustand without rewriting logic.

Frequently Asked Questions

Can I use a Zustand vanilla store in Node.js?

Yes. Because zustand/vanilla has no React dependencies, you can import createStore into any Node.js script or server-side application. The store works identically in Node.js, Deno, Bun, or browser workers as it does in the browser main thread.

What is the difference between create and createStore?

create (the default export from zustand) is a convenience wrapper that creates a vanilla store and immediately binds it to React via the useStore hook. createStore (from zustand/vanilla) returns the raw store object with getState, setState, and subscribe, giving you explicit control without React coupling. Use createStore when you need to use Zustand vanilla store without React.

How do I subscribe to state changes without React?

Call the subscribe method on your store instance and pass a callback that receives (newState, previousState). The method returns an unsubscribe function:

const unsubscribe = store.subscribe((newState, prevState) => {
  console.log('State changed:', newState)
})
// Later: unsubscribe()

This subscription mechanism (implemented in src/vanilla.ts at lines L88-L92) is synchronous and fires on every state mutation.

Can I use middleware with the vanilla store?

Yes. Middleware like persist, immer, or devtools can wrap a vanilla store created with createStore. Since the vanilla API is the foundation of all Zustand stores, middleware functions that accept a store creator work identically whether you use React or not. Import middleware from zustand/middleware and apply it to your vanilla store creator.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →