Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/pink-guests-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'zustand-x': minor
---

- Introduced a React-free `createVanillaStore` in `zustand-x/vanilla` using `zustand/vanilla`.
- Extracted shared internal logic (`middleware`, `options parsing`, `selector/action helpers`) to `src/internal` for reuse.
- Refactored React entry (`createStore`, hooks) to use shared internal logic without breaking existing API.
- Split types: base hook-free definitions vs React-specific types for compatibility with both entries.
- Updated package exports to include `./vanilla` while keeping `.` for React.
- Added vanilla-focused tests to ensure store functionality works without React.
- Ensured middleware, selector/action extensions, and mutative utilities work in both vanilla and React contexts.
- **Example usage (vanilla, no React):**

```ts
import { createVanillaStore } from 'zustand-x/vanilla';

const counterStore = createVanillaStore(
{ count: 0 },
{ name: 'vanilla-counter', persist: true }
)
.extendSelectors(({ get }) => ({
doubled: () => get('count') * 2,
}))
.extendActions(({ set }) => ({
increment: () => set('count', (value) => value + 1),
}));

counterStore.actions.increment();
console.log(counterStore.get('doubled')); // 2
```
10 changes: 9 additions & 1 deletion config/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,18 @@ const INPUT_FILE = fs.existsSync(INPUT_FILE_PATH)
? INPUT_FILE_PATH
: path.join(PACKAGE_ROOT_PATH, 'src/index.tsx');

const VANILLA_ENTRY_PATH = path.join(PACKAGE_ROOT_PATH, 'src/lib/index.ts');

export default defineConfig((opts) => {
const entryPoints = [INPUT_FILE];

if (fs.existsSync(VANILLA_ENTRY_PATH)) {
entryPoints.push(VANILLA_ENTRY_PATH);
}

return {
...opts,
entry: [INPUT_FILE],
entry: entryPoints,
format: ['cjs', 'esm'],
dts: true,
sourcemap: true,
Expand Down
29 changes: 29 additions & 0 deletions packages/zustand-x/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,35 @@ function AddStarButton() {
}
```

## Vanilla Usage (No React)

Need the same ergonomics without bundling React? Import from the `zustand-x/vanilla` entry to get a plain Zustand store augmented with actions and selectors.

```ts
import { createVanillaStore } from 'zustand-x/vanilla';

const counterStore = createVanillaStore(
{
count: 0,
},
{
name: 'vanilla-counter',
persist: true,
}
)
.extendSelectors(({ get }) => ({
doubled: () => get('count') * 2,
}))
.extendActions(({ set }) => ({
increment: () => set('count', (value) => value + 1),
}));

counterStore.actions.increment();
console.log(counterStore.get('doubled')); // 2
```

This API exposes `get`, `set`, `subscribe`, `extendSelectors`, and `extendActions` without attaching any React hooks, so it can run in Node.js, workers, or any non-React environment.

## Core Concepts

### Store Configuration
Expand Down
6 changes: 6 additions & 0 deletions packages/zustand-x/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@
"import": "./dist/index.mjs",
"module": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./vanilla": {
"types": "./dist/lib/index.d.ts",
"import": "./dist/lib/index.mjs",
"module": "./dist/lib/index.mjs",
"require": "./dist/lib/index.js"
}
},
"scripts": {
Expand Down
152 changes: 10 additions & 142 deletions packages/zustand-x/src/createStore.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
import { createTrackedSelector } from 'react-tracked';
import { subscribeWithSelector } from 'zustand/middleware';
import { createWithEqualityFn as createStoreZustand } from 'zustand/traditional';

import {
devToolsMiddleware,
immerMiddleware,
persistMiddleware,
} from './middlewares';
import { mutativeMiddleware } from './middlewares/mutative';
import { buildStateCreator } from './internal/buildStateCreator';
import { createBaseApi } from './internal/createBaseApi';
import { DefaultMutators, TBaseStoreOptions, TState } from './types';
import { TMiddleware } from './types/middleware';
import { getOptions } from './utils/helpers';
import { storeFactory } from './utils/storeFactory';

import type { TStateApiForBuilder } from './types';
Expand All @@ -34,139 +27,19 @@ export const createStore = <
options: CreateStoreOptions
) => {
type Mutators = [...DefaultMutators<StateType, CreateStoreOptions>, ...Mcs];
const {
name,
devtools: devtoolsOptions,
immer: immerOptions,
mutative: mutativeOptions,
persist: persistOptions,
isMutativeState,
} = options;

//current middlewares order devTools(persist(immer(initiator)))
const middlewares: TMiddleware[] = [];

//enable devtools
const _devtoolsOptionsInternal = getOptions(devtoolsOptions);
if (_devtoolsOptionsInternal.enabled) {
middlewares.push((config) =>
devToolsMiddleware(config, {
..._devtoolsOptionsInternal,
name: _devtoolsOptionsInternal?.name ?? name,
})
);
}

//enable persist
const _persistOptionsInternal = getOptions(persistOptions);
if (_persistOptionsInternal.enabled) {
middlewares.push((config) =>
persistMiddleware(config, {
..._persistOptionsInternal,
name: _persistOptionsInternal.name ?? name,
})
);
}

//enable immer
const _immerOptionsInternal = getOptions(immerOptions);
if (_immerOptionsInternal.enabled) {
middlewares.push((config) =>
immerMiddleware(config, _immerOptionsInternal)
);
}

//enable mutative
const _mutativeOptionsInternal = getOptions(mutativeOptions);
if (_mutativeOptionsInternal.enabled) {
middlewares.push((config) =>
mutativeMiddleware(config, _mutativeOptionsInternal)
);
}

const stateMutators = middlewares
.reverse()
.reduce(
(y, fn) => fn(y),
(typeof initializer === 'function'
? initializer
: () => initializer) as StateCreator<StateType>
) as StateCreator<StateType, [], Mutators>;

const store = createStoreZustand(subscribeWithSelector(stateMutators));
const builder = buildStateCreator(initializer, options);
const store = createStoreZustand(builder.stateCreator);

const useTrackedStore = createTrackedSelector(store);

const useTracked = (key: string) => {
return useTrackedStore()[key as keyof StateType];
};

const getFn = (key: string) => {
if (key === 'state') {
return store.getState();
}

return store.getState()[key as keyof StateType];
};

const subscribeFn = (
key: string,
selector: any,
listener: any,
subscribeOptions: any
) => {
if (key === 'state') {
// @ts-expect-error -- typescript is unable to infer the 3 args version
return store.subscribe(selector, listener, subscribeOptions);
}

let wrappedSelector: any;

if (listener) {
// subscribe(selector, listener, subscribeOptions) variant
wrappedSelector = (state: StateType) =>
selector(state[key as keyof StateType]);
} else {
// subscribe(listener) variant
listener = selector;
wrappedSelector = (state: StateType) => state[key as keyof StateType];
}

// @ts-expect-error -- typescript is unable to infer the 3 args version
return store.subscribe(wrappedSelector, listener, subscribeOptions);
};

const isMutative =
isMutativeState ||
_immerOptionsInternal.enabled ||
_mutativeOptionsInternal.enabled;

const setFn = (key: string, value: any) => {
if (key === 'state') {
return (store.setState as any)(value);
}

const typedKey = key as keyof StateType;
const prevValue = store.getState()[typedKey];

if (typeof value === 'function') {
value = value(prevValue);
}
if (prevValue === value) return;

const actionKey = key.replace(/^\S/, (s) => s.toUpperCase());
const debugLog = name ? `@@${name}/set${actionKey}` : undefined;

(store.setState as any)?.(
isMutative
? (draft: StateType) => {
draft[typedKey] = value;
}
: { [typedKey]: value },
undefined,
debugLog
);
};
const baseApi = createBaseApi<StateType, Mutators, {}, {}>(store, {
name: builder.name,
isMutative: builder.isMutative,
});

const useValue = (
key: string,
Expand All @@ -181,22 +54,17 @@ export const createStore = <
) => {
const value = useValue(key, equalityFn);

return [value, (val: any) => setFn(key, val)];
return [value, (val: any) => baseApi.set(key as keyof StateType, val)];
};

const apiInternal = {
get: getFn,
name,
set: setFn,
subscribe: subscribeFn,
...baseApi,
store,
useStore: store,
useValue,
useState,
useTracked,
useTrackedStore,
actions: {},
selectors: {},
} as any as TStateApiForBuilder<StateType, Mutators>;

return storeFactory(apiInternal);
Expand Down
93 changes: 93 additions & 0 deletions packages/zustand-x/src/internal/buildStateCreator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { subscribeWithSelector } from 'zustand/middleware';

import {
devToolsMiddleware,
immerMiddleware,
persistMiddleware,
} from '../middlewares';
import { mutativeMiddleware } from '../middlewares/mutative';
import { DefaultMutators, TBaseStoreOptions, TState } from '../types';
import { TMiddleware } from '../types/middleware';
import { getOptions } from '../utils/helpers';

import type { StateCreator, StoreMutatorIdentifier } from 'zustand';

type BuildStateCreatorResult<
StateType extends TState,
Mutators extends [StoreMutatorIdentifier, unknown][],
> = {
stateCreator: StateCreator<StateType, [], Mutators>;
isMutative: boolean;
name: string;
};

export const buildStateCreator = <
StateType extends TState,
Mps extends [StoreMutatorIdentifier, unknown][] = [],
Mcs extends [StoreMutatorIdentifier, unknown][] = [],
CreateStoreOptions extends
TBaseStoreOptions<StateType> = TBaseStoreOptions<StateType>,
>(
initializer: StateType | StateCreator<StateType, Mps, Mcs>,
options: CreateStoreOptions
) => {
type Mutators = [...DefaultMutators<StateType, CreateStoreOptions>, ...Mcs];
const {
name,
devtools: devtoolsOptions,
immer: immerOptions,
mutative: mutativeOptions,
persist: persistOptions,
isMutativeState,
} = options;

const middlewares: TMiddleware[] = [];

const devtoolsConfig = getOptions(devtoolsOptions);
if (devtoolsConfig.enabled) {
middlewares.push((config) =>
devToolsMiddleware(config, {
...devtoolsConfig,
name: devtoolsConfig?.name ?? name,
})
);
}

const persistConfig = getOptions(persistOptions);
if (persistConfig.enabled) {
middlewares.push((config) =>
persistMiddleware(config, {
...persistConfig,
name: persistConfig.name ?? name,
})
);
}

const immerConfig = getOptions(immerOptions);
if (immerConfig.enabled) {
middlewares.push((config) => immerMiddleware(config, immerConfig));
}

const mutativeConfig = getOptions(mutativeOptions);
if (mutativeConfig.enabled) {
middlewares.push((config) => mutativeMiddleware(config, mutativeConfig));
}

const stateCreator = middlewares
.reverse()
.reduce(
(creator, middleware) => middleware(creator),
(typeof initializer === 'function'
? initializer
: () => initializer) as StateCreator<StateType>
) as StateCreator<StateType, [], Mutators>;

const isMutative =
isMutativeState || immerConfig.enabled || mutativeConfig.enabled;

return {
stateCreator: subscribeWithSelector(stateCreator),
isMutative,
name,
} as BuildStateCreatorResult<StateType, Mutators>;
};
Loading