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,
setStateshallow-merges objects. If you passtrueas the second argument (linesL75-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(lineL73).
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: UsecreateStoreinstead of the defaultcreateto eliminate React dependencies and reduce bundle size. - Four core methods: Control state with
getState,setState,subscribe, andgetInitialStateas implemented insrc/vanilla.ts. - Flexible updates:
setStateshallow-merges objects by default; passtrueas the second argument to replace the entire state (linesL75-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
useStorehook fromzustandwithout 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →