Skip to content

Commit eefe25a

Browse files
authored
Merge pull request #117 from udecode/react/decouple
Decouple react
2 parents 3dc3fed + 9389b32 commit eefe25a

19 files changed

+876
-410
lines changed

.changeset/pink-guests-jump.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'zustand-x': minor
3+
---
4+
5+
- Introduced a React-free `createVanillaStore` in `zustand-x/vanilla` using `zustand/vanilla`.
6+
- Extracted shared internal logic (`middleware`, `options parsing`, `selector/action helpers`) to `src/internal` for reuse.
7+
- Refactored React entry (`createStore`, hooks) to use shared internal logic without breaking existing API.
8+
- Split types: base hook-free definitions vs React-specific types for compatibility with both entries.
9+
- Updated package exports to include `./vanilla` while keeping `.` for React.
10+
- Added vanilla-focused tests to ensure store functionality works without React.
11+
- Ensured middleware, selector/action extensions, and mutative utilities work in both vanilla and React contexts.
12+
- **Example usage (vanilla, no React):**
13+
14+
```ts
15+
import { createVanillaStore } from 'zustand-x/vanilla';
16+
17+
const counterStore = createVanillaStore(
18+
{ count: 0 },
19+
{ name: 'vanilla-counter', persist: true }
20+
)
21+
.extendSelectors(({ get }) => ({
22+
doubled: () => get('count') * 2,
23+
}))
24+
.extendActions(({ set }) => ({
25+
increment: () => set('count', (value) => value + 1),
26+
}));
27+
28+
counterStore.actions.increment();
29+
console.log(counterStore.get('doubled')); // 2
30+
```

config/tsup.config.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,18 @@ const INPUT_FILE = fs.existsSync(INPUT_FILE_PATH)
1111
? INPUT_FILE_PATH
1212
: path.join(PACKAGE_ROOT_PATH, 'src/index.tsx');
1313

14+
const VANILLA_ENTRY_PATH = path.join(PACKAGE_ROOT_PATH, 'src/lib/index.ts');
15+
1416
export default defineConfig((opts) => {
17+
const entryPoints = [INPUT_FILE];
18+
19+
if (fs.existsSync(VANILLA_ENTRY_PATH)) {
20+
entryPoints.push(VANILLA_ENTRY_PATH);
21+
}
22+
1523
return {
1624
...opts,
17-
entry: [INPUT_FILE],
25+
entry: entryPoints,
1826
format: ['cjs', 'esm'],
1927
dts: true,
2028
sourcemap: true,

packages/zustand-x/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,35 @@ function AddStarButton() {
5656
}
5757
```
5858

59+
## Vanilla Usage (No React)
60+
61+
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.
62+
63+
```ts
64+
import { createVanillaStore } from 'zustand-x/vanilla';
65+
66+
const counterStore = createVanillaStore(
67+
{
68+
count: 0,
69+
},
70+
{
71+
name: 'vanilla-counter',
72+
persist: true,
73+
}
74+
)
75+
.extendSelectors(({ get }) => ({
76+
doubled: () => get('count') * 2,
77+
}))
78+
.extendActions(({ set }) => ({
79+
increment: () => set('count', (value) => value + 1),
80+
}));
81+
82+
counterStore.actions.increment();
83+
console.log(counterStore.get('doubled')); // 2
84+
```
85+
86+
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.
87+
5988
## Core Concepts
6089

6190
### Store Configuration

packages/zustand-x/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@
2424
"import": "./dist/index.mjs",
2525
"module": "./dist/index.mjs",
2626
"require": "./dist/index.js"
27+
},
28+
"./vanilla": {
29+
"types": "./dist/lib/index.d.ts",
30+
"import": "./dist/lib/index.mjs",
31+
"module": "./dist/lib/index.mjs",
32+
"require": "./dist/lib/index.js"
2733
}
2834
},
2935
"scripts": {

packages/zustand-x/src/createStore.ts

Lines changed: 10 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,9 @@
11
import { createTrackedSelector } from 'react-tracked';
2-
import { subscribeWithSelector } from 'zustand/middleware';
32
import { createWithEqualityFn as createStoreZustand } from 'zustand/traditional';
43

5-
import {
6-
devToolsMiddleware,
7-
immerMiddleware,
8-
persistMiddleware,
9-
} from './middlewares';
10-
import { mutativeMiddleware } from './middlewares/mutative';
4+
import { buildStateCreator } from './internal/buildStateCreator';
5+
import { createBaseApi } from './internal/createBaseApi';
116
import { DefaultMutators, TBaseStoreOptions, TState } from './types';
12-
import { TMiddleware } from './types/middleware';
13-
import { getOptions } from './utils/helpers';
147
import { storeFactory } from './utils/storeFactory';
158

169
import type { TStateApiForBuilder } from './types';
@@ -34,139 +27,19 @@ export const createStore = <
3427
options: CreateStoreOptions
3528
) => {
3629
type Mutators = [...DefaultMutators<StateType, CreateStoreOptions>, ...Mcs];
37-
const {
38-
name,
39-
devtools: devtoolsOptions,
40-
immer: immerOptions,
41-
mutative: mutativeOptions,
42-
persist: persistOptions,
43-
isMutativeState,
44-
} = options;
45-
46-
//current middlewares order devTools(persist(immer(initiator)))
47-
const middlewares: TMiddleware[] = [];
48-
49-
//enable devtools
50-
const _devtoolsOptionsInternal = getOptions(devtoolsOptions);
51-
if (_devtoolsOptionsInternal.enabled) {
52-
middlewares.push((config) =>
53-
devToolsMiddleware(config, {
54-
..._devtoolsOptionsInternal,
55-
name: _devtoolsOptionsInternal?.name ?? name,
56-
})
57-
);
58-
}
59-
60-
//enable persist
61-
const _persistOptionsInternal = getOptions(persistOptions);
62-
if (_persistOptionsInternal.enabled) {
63-
middlewares.push((config) =>
64-
persistMiddleware(config, {
65-
..._persistOptionsInternal,
66-
name: _persistOptionsInternal.name ?? name,
67-
})
68-
);
69-
}
70-
71-
//enable immer
72-
const _immerOptionsInternal = getOptions(immerOptions);
73-
if (_immerOptionsInternal.enabled) {
74-
middlewares.push((config) =>
75-
immerMiddleware(config, _immerOptionsInternal)
76-
);
77-
}
78-
79-
//enable mutative
80-
const _mutativeOptionsInternal = getOptions(mutativeOptions);
81-
if (_mutativeOptionsInternal.enabled) {
82-
middlewares.push((config) =>
83-
mutativeMiddleware(config, _mutativeOptionsInternal)
84-
);
85-
}
86-
87-
const stateMutators = middlewares
88-
.reverse()
89-
.reduce(
90-
(y, fn) => fn(y),
91-
(typeof initializer === 'function'
92-
? initializer
93-
: () => initializer) as StateCreator<StateType>
94-
) as StateCreator<StateType, [], Mutators>;
95-
96-
const store = createStoreZustand(subscribeWithSelector(stateMutators));
30+
const builder = buildStateCreator(initializer, options);
31+
const store = createStoreZustand(builder.stateCreator);
9732

9833
const useTrackedStore = createTrackedSelector(store);
9934

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

104-
const getFn = (key: string) => {
105-
if (key === 'state') {
106-
return store.getState();
107-
}
108-
109-
return store.getState()[key as keyof StateType];
110-
};
111-
112-
const subscribeFn = (
113-
key: string,
114-
selector: any,
115-
listener: any,
116-
subscribeOptions: any
117-
) => {
118-
if (key === 'state') {
119-
// @ts-expect-error -- typescript is unable to infer the 3 args version
120-
return store.subscribe(selector, listener, subscribeOptions);
121-
}
122-
123-
let wrappedSelector: any;
124-
125-
if (listener) {
126-
// subscribe(selector, listener, subscribeOptions) variant
127-
wrappedSelector = (state: StateType) =>
128-
selector(state[key as keyof StateType]);
129-
} else {
130-
// subscribe(listener) variant
131-
listener = selector;
132-
wrappedSelector = (state: StateType) => state[key as keyof StateType];
133-
}
134-
135-
// @ts-expect-error -- typescript is unable to infer the 3 args version
136-
return store.subscribe(wrappedSelector, listener, subscribeOptions);
137-
};
138-
139-
const isMutative =
140-
isMutativeState ||
141-
_immerOptionsInternal.enabled ||
142-
_mutativeOptionsInternal.enabled;
143-
144-
const setFn = (key: string, value: any) => {
145-
if (key === 'state') {
146-
return (store.setState as any)(value);
147-
}
148-
149-
const typedKey = key as keyof StateType;
150-
const prevValue = store.getState()[typedKey];
151-
152-
if (typeof value === 'function') {
153-
value = value(prevValue);
154-
}
155-
if (prevValue === value) return;
156-
157-
const actionKey = key.replace(/^\S/, (s) => s.toUpperCase());
158-
const debugLog = name ? `@@${name}/set${actionKey}` : undefined;
159-
160-
(store.setState as any)?.(
161-
isMutative
162-
? (draft: StateType) => {
163-
draft[typedKey] = value;
164-
}
165-
: { [typedKey]: value },
166-
undefined,
167-
debugLog
168-
);
169-
};
39+
const baseApi = createBaseApi<StateType, Mutators, {}, {}>(store, {
40+
name: builder.name,
41+
isMutative: builder.isMutative,
42+
});
17043

17144
const useValue = (
17245
key: string,
@@ -181,22 +54,17 @@ export const createStore = <
18154
) => {
18255
const value = useValue(key, equalityFn);
18356

184-
return [value, (val: any) => setFn(key, val)];
57+
return [value, (val: any) => baseApi.set(key as keyof StateType, val)];
18558
};
18659

18760
const apiInternal = {
188-
get: getFn,
189-
name,
190-
set: setFn,
191-
subscribe: subscribeFn,
61+
...baseApi,
19262
store,
19363
useStore: store,
19464
useValue,
19565
useState,
19666
useTracked,
19767
useTrackedStore,
198-
actions: {},
199-
selectors: {},
20068
} as any as TStateApiForBuilder<StateType, Mutators>;
20169

20270
return storeFactory(apiInternal);
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { subscribeWithSelector } from 'zustand/middleware';
2+
3+
import {
4+
devToolsMiddleware,
5+
immerMiddleware,
6+
persistMiddleware,
7+
} from '../middlewares';
8+
import { mutativeMiddleware } from '../middlewares/mutative';
9+
import { DefaultMutators, TBaseStoreOptions, TState } from '../types';
10+
import { TMiddleware } from '../types/middleware';
11+
import { getOptions } from '../utils/helpers';
12+
13+
import type { StateCreator, StoreMutatorIdentifier } from 'zustand';
14+
15+
type BuildStateCreatorResult<
16+
StateType extends TState,
17+
Mutators extends [StoreMutatorIdentifier, unknown][],
18+
> = {
19+
stateCreator: StateCreator<StateType, [], Mutators>;
20+
isMutative: boolean;
21+
name: string;
22+
};
23+
24+
export const buildStateCreator = <
25+
StateType extends TState,
26+
Mps extends [StoreMutatorIdentifier, unknown][] = [],
27+
Mcs extends [StoreMutatorIdentifier, unknown][] = [],
28+
CreateStoreOptions extends
29+
TBaseStoreOptions<StateType> = TBaseStoreOptions<StateType>,
30+
>(
31+
initializer: StateType | StateCreator<StateType, Mps, Mcs>,
32+
options: CreateStoreOptions
33+
) => {
34+
type Mutators = [...DefaultMutators<StateType, CreateStoreOptions>, ...Mcs];
35+
const {
36+
name,
37+
devtools: devtoolsOptions,
38+
immer: immerOptions,
39+
mutative: mutativeOptions,
40+
persist: persistOptions,
41+
isMutativeState,
42+
} = options;
43+
44+
const middlewares: TMiddleware[] = [];
45+
46+
const devtoolsConfig = getOptions(devtoolsOptions);
47+
if (devtoolsConfig.enabled) {
48+
middlewares.push((config) =>
49+
devToolsMiddleware(config, {
50+
...devtoolsConfig,
51+
name: devtoolsConfig?.name ?? name,
52+
})
53+
);
54+
}
55+
56+
const persistConfig = getOptions(persistOptions);
57+
if (persistConfig.enabled) {
58+
middlewares.push((config) =>
59+
persistMiddleware(config, {
60+
...persistConfig,
61+
name: persistConfig.name ?? name,
62+
})
63+
);
64+
}
65+
66+
const immerConfig = getOptions(immerOptions);
67+
if (immerConfig.enabled) {
68+
middlewares.push((config) => immerMiddleware(config, immerConfig));
69+
}
70+
71+
const mutativeConfig = getOptions(mutativeOptions);
72+
if (mutativeConfig.enabled) {
73+
middlewares.push((config) => mutativeMiddleware(config, mutativeConfig));
74+
}
75+
76+
const stateCreator = middlewares
77+
.reverse()
78+
.reduce(
79+
(creator, middleware) => middleware(creator),
80+
(typeof initializer === 'function'
81+
? initializer
82+
: () => initializer) as StateCreator<StateType>
83+
) as StateCreator<StateType, [], Mutators>;
84+
85+
const isMutative =
86+
isMutativeState || immerConfig.enabled || mutativeConfig.enabled;
87+
88+
return {
89+
stateCreator: subscribeWithSelector(stateCreator),
90+
isMutative,
91+
name,
92+
} as BuildStateCreatorResult<StateType, Mutators>;
93+
};

0 commit comments

Comments
 (0)