diff --git a/CHANGELOG.md b/CHANGELOG.md
index ceb577e..80a5e6c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
## [Unreleased]
+## Added
+
+- feat: support nested objects and slice pattern #7
+
## [0.3.0] - 2024-07-13
## Added
diff --git a/README.md b/README.md
index 67c32f2..e153dda 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,7 @@ You can also try them directly:
[01](https://stackblitz.com/github/zustandjs/zustand-valtio/tree/main/examples/01_counter)
[02](https://stackblitz.com/github/zustandjs/zustand-valtio/tree/main/examples/02_methods)
[03](https://stackblitz.com/github/zustandjs/zustand-valtio/tree/main/examples/03_middleware)
+[04](https://stackblitz.com/github/zustandjs/zustand-valtio/tree/main/examples/04_slices)
## Tweets
diff --git a/examples/04_slices/index.html b/examples/04_slices/index.html
new file mode 100644
index 0000000..ec005a3
--- /dev/null
+++ b/examples/04_slices/index.html
@@ -0,0 +1,9 @@
+
+
+ example
+
+
+
+
+
+
diff --git a/examples/04_slices/package.json b/examples/04_slices/package.json
new file mode 100644
index 0000000..12e720a
--- /dev/null
+++ b/examples/04_slices/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "example",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "react": "latest",
+ "react-dom": "latest",
+ "valtio": "latest",
+ "zustand": "latest",
+ "zustand-valtio": "latest"
+ },
+ "devDependencies": {
+ "@types/react": "latest",
+ "@types/react-dom": "latest",
+ "typescript": "latest",
+ "vite": "latest"
+ },
+ "scripts": {
+ "dev": "vite"
+ }
+}
diff --git a/examples/04_slices/src/app.tsx b/examples/04_slices/src/app.tsx
new file mode 100644
index 0000000..60ca1c0
--- /dev/null
+++ b/examples/04_slices/src/app.tsx
@@ -0,0 +1,67 @@
+import { create } from 'zustand';
+import { withProxy } from 'zustand-valtio';
+
+const countSlice = {
+ value: 0,
+ inc() {
+ this.value++;
+ },
+ reset() {
+ this.value = 0;
+ },
+};
+
+const textSlice = {
+ value: 'Hello',
+ updateText(newText: string) {
+ this.value = newText;
+ },
+ reset() {
+ this.value = 'Hello';
+ },
+};
+
+const useCounterState = create(
+ withProxy({
+ count: countSlice,
+ text: textSlice,
+ reset() {
+ this.count.reset();
+ this.text.reset();
+ },
+ }),
+);
+
+const Counter = () => {
+ const count = useCounterState((state) => state.count.value);
+ const inc = useCounterState((state) => state.count.inc);
+ const text = useCounterState((state) => state.text.value);
+ const updateText = useCounterState((state) => state.text.updateText);
+ const reset = useCounterState((state) => state.reset);
+ return (
+ <>
+
+ Count: {count}
+
+
+
+ updateText(e.target.value)} />
+
+
+
+
+ >
+ );
+};
+
+const App = () => (
+
+
+
+);
+
+export default App;
diff --git a/examples/04_slices/src/main.tsx b/examples/04_slices/src/main.tsx
new file mode 100644
index 0000000..1a72c01
--- /dev/null
+++ b/examples/04_slices/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+
+import App from './app';
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/examples/04_slices/tsconfig.json b/examples/04_slices/tsconfig.json
new file mode 100644
index 0000000..f9e0a7e
--- /dev/null
+++ b/examples/04_slices/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "strict": true,
+ "target": "es2018",
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "skipLibCheck": true,
+ "allowJs": true,
+ "noUncheckedIndexedAccess": true,
+ "exactOptionalPropertyTypes": true,
+ "jsx": "react-jsx"
+ }
+}
diff --git a/package.json b/package.json
index f60c185..d5948ae 100644
--- a/package.json
+++ b/package.json
@@ -42,7 +42,8 @@
"test:spec": "vitest run",
"examples:01_counter": "DIR=01_counter vite",
"examples:02_methods": "DIR=02_methods vite",
- "examples:03_middleware": "DIR=03_middleware vite"
+ "examples:03_middleware": "DIR=03_middleware vite",
+ "examples:04_slices": "DIR=04_slices vite"
},
"keywords": [
"react",
diff --git a/src/index.ts b/src/index.ts
index 99c6768..5052f31 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,15 +1,20 @@
import type { StateCreator, StoreApi, StoreMutatorIdentifier } from 'zustand';
import { proxy, snapshot, subscribe } from 'valtio/vanilla';
+type AnyFunction = (...args: never[]) => unknown;
+
+type DeepWritable = T extends object
+ ? { -readonly [K in keyof T]: DeepWritable }
+ : T;
+
// TODO replace with Valtio's Snapshot type in v2
-type Snapshot = T extends (...args: never[]) => unknown
+type Snapshot = T extends AnyFunction
? T
: T extends object
? { readonly [K in keyof T]: Snapshot }
: T;
type StoreWithProxy = {
- setState: never;
getProxyState: () => T;
};
@@ -19,10 +24,6 @@ type WithWithProxy = S extends { getState: () => Snapshot }
? Write>>
: never;
-type DeepWritable = T extends object
- ? { -readonly [K in keyof T]: DeepWritable }
- : T;
-
declare module 'zustand/vanilla' {
interface StoreMutators {
'zustand-valtio': WithWithProxy;
@@ -41,8 +42,51 @@ type WithProxyImpl = (
initialObject: T,
) => StateCreator, [], []>;
+const isObject = (x: unknown): x is object =>
+ typeof x === 'object' && x !== null && !(x instanceof Promise);
+
+const patchObjectMethods = (
+ proxyObject: T,
+ fn: (obj: object, key: string, fn: AnyFunction) => void,
+) => {
+ (Object.getOwnPropertyNames(proxyObject) as (keyof T)[]).forEach((key) => {
+ const value = proxyObject[key];
+ if (typeof value === 'function') {
+ fn(proxyObject, key as string, value as AnyFunction);
+ } else if (isObject(value)) {
+ patchObjectMethods(value, fn);
+ }
+ });
+};
+
+const applyChanges = (proxyObject: T, prev: T, next: T) => {
+ (Object.getOwnPropertyNames(prev) as (keyof T)[]).forEach((key) => {
+ if (!(key in next)) {
+ delete proxyObject[key];
+ } else if (Object.is(prev[key], next[key])) {
+ // unchanged
+ } else if (
+ isObject(proxyObject[key]) &&
+ isObject(prev[key]) &&
+ isObject(next[key])
+ ) {
+ applyChanges(
+ proxyObject[key] as unknown as object,
+ prev[key] as unknown as object,
+ next[key] as unknown as object,
+ );
+ } else {
+ proxyObject[key] = next[key];
+ }
+ });
+ (Object.keys(next) as (keyof T)[]).forEach((key) => {
+ if (!(key in prev)) {
+ proxyObject[key] = next[key];
+ }
+ });
+};
+
const withProxyImpl: WithProxyImpl = (initialObject) => (set, get, api) => {
- type AnyObject = Record;
const proxyState = proxy(initialObject);
let mutating = 0;
let updating = 0;
@@ -53,37 +97,30 @@ const withProxyImpl: WithProxyImpl = (initialObject) => (set, get, api) => {
--updating;
}
};
- Object.keys(proxyState).forEach((key) => {
- const fn = (proxyState as AnyObject)[key];
- // TODO this doesn't handle nested objects
- if (typeof fn === 'function') {
- (proxyState as AnyObject)[key] = (...args: never[]) => {
+ patchObjectMethods(proxyState, (obj, key, fn) => {
+ Object.defineProperty(obj, key, {
+ value: (...args: never[]) => {
try {
++mutating;
- return fn.apply(proxyState, args);
+ return fn.apply(obj, args);
} finally {
--mutating;
updateState();
}
- };
- }
+ },
+ });
});
type Api = StoreApi & StoreWithProxy;
- delete (api as Api).setState;
(api as Api).getProxyState = () => proxyState;
subscribe(proxyState, updateState, true);
api.subscribe(() => {
if (!updating) {
// HACK for persist hydration
- const state = get() as AnyObject;
- Object.keys(state).forEach((key) => {
- const val = state[key];
- // TODO this doesn't handle nested objects
- if (typeof val !== 'function') {
- // XXX this will throw if val is a snapshot
- (proxyState as AnyObject)[key] = val;
- }
- });
+ applyChanges(
+ proxyState,
+ snapshot(proxyState) as typeof proxyState,
+ get() as typeof proxyState,
+ );
}
});
return snapshot(proxyState) as Snapshot;