diff --git a/package.json b/package.json
index e1cef8f..c520d00 100644
--- a/package.json
+++ b/package.json
@@ -29,5 +29,8 @@
"license": "MIT",
"devDependencies": {
"prettier": "^3.6.2"
+ },
+ "prettier": {
+ "trailingComma": "all"
}
}
diff --git a/packages/use-store/src/experimental/useStore.spec.tsx b/packages/use-store/src/experimental/useStore.spec.tsx
index 50e0cd0..b8462ec 100644
--- a/packages/use-store/src/experimental/useStore.spec.tsx
+++ b/packages/use-store/src/experimental/useStore.spec.tsx
@@ -998,58 +998,6 @@ describe("Experimental Userland Store", () => {
expect(store._listeners.length).toBe(0);
});
- it("dynamic selectors are not yet supported", async () => {
- const store = createStore(reducer, 1);
-
- let setSelector: any;
- function Count({ testid }: { testid: string }) {
- const [selector, _setSelector] = useState(() => identity);
- setSelector = _setSelector;
- const count = useStoreSelector(store, selector);
- logger.log({ testid, count });
- return
{count}
;
- }
-
- function App() {
- return (
-
-
-
- );
- }
-
- const { asFragment, unmount } = await act(async () => {
- return render();
- });
-
- logger.assertLog([{ testid: "count", count: 1 }]);
-
- expect(asFragment()).toMatchInlineSnapshot(`
-
-
- 1
-
-
- `);
-
- let error: any;
- try {
- await act(async () => {
- setSelector((s: number) => s * 2);
- });
- } catch (e) {
- error = e;
- }
-
- logger.assertLog([]);
-
- expect(error.message).toMatch(
- "useStoreSelector does not currently support dynamic selectors",
- );
- unmount();
- expect(store._listeners.length).toBe(0);
- });
-
it("dynamic stores are not yet supported", async () => {
const store1 = createStore(reducer, 1);
const store2 = createStore(reducer, 10);
@@ -1367,3 +1315,144 @@ describe("Experimental Userland Store", () => {
expect(storeB._listeners.length).toBe(0);
});
});
+
+describe("Selectors can be dynamic", () => {
+ it("dynamic selectors are supported", async () => {
+ const store = createStore(reducer, 1);
+
+ let setSelector: any;
+ function Count({ testid }: { testid: string }) {
+ const [selector, _setSelector] = useState(() => identity);
+ setSelector = _setSelector;
+ const count = useStoreSelector(store, selector);
+ logger.log({ testid, count });
+ return {count}
;
+ }
+
+ function App() {
+ return (
+
+
+
+ );
+ }
+
+ const { asFragment, unmount } = await act(async () => {
+ return render();
+ });
+
+ logger.assertLog([{ testid: "count", count: 1 }]);
+
+ expect(asFragment()).toMatchInlineSnapshot(`
+
+
+ 1
+
+
+ `);
+
+ await act(async () => {
+ setSelector(() => (s: number) => s * 2);
+ });
+
+ logger.assertLog([{ testid: "count", count: 2 }]);
+
+ expect(asFragment()).toMatchInlineSnapshot(`
+
+
+ 2
+
+
+ `);
+
+ unmount();
+ expect(store._listeners.length).toBe(0);
+ });
+
+ it("selector changes sync during a transition update to the store", async () => {
+ const store = createStore(reducer, 1);
+
+ let setSelector: any;
+ function Count({ testid }: { testid: string }) {
+ const [selector, _setSelector] = useState(() => identity);
+ setSelector = _setSelector;
+ const count = useStoreSelector(store, selector);
+ logger.log({ testid, count });
+ return {count}
;
+ }
+
+ function App() {
+ return (
+
+
+
+ );
+ }
+
+ const { asFragment, unmount } = await act(async () => {
+ return render();
+ });
+
+ logger.assertLog([{ testid: "count", count: 1 }]);
+
+ expect(asFragment()).toMatchInlineSnapshot(`
+
+
+ 1
+
+
+ `);
+
+ let resolve: () => void;
+
+ const promise = new Promise((_resolve) => {
+ resolve = _resolve;
+ });
+
+ await act(async () => {
+ startTransition(async () => {
+ store.dispatch({ type: "INCREMENT" });
+ await promise;
+ });
+ });
+
+ await act(async () => {
+ setSelector(() => (s: number) => s * 2);
+ });
+
+ logger.assertLog([
+ // Just like a fresh mount, the new selector is run on the transition state...
+ { testid: "count", count: 4 },
+ // But gets fixed up to the sync state in the layoutEffect before yielding to the user.
+ { testid: "count", count: 2 },
+ ]);
+
+ expect(asFragment()).toMatchInlineSnapshot(`
+
+
+ 2
+
+
+ `);
+
+ await act(async () => {
+ resolve();
+ });
+
+ logger.assertLog([
+ // Now we render the transition state
+ { testid: "count", count: 4 },
+ ]);
+
+ expect(asFragment()).toMatchInlineSnapshot(`
+
+
+ 4
+
+
+ `);
+
+ unmount();
+ expect(store._listeners.length).toBe(0);
+ });
+});
diff --git a/packages/use-store/src/experimental/useStore.tsx b/packages/use-store/src/experimental/useStore.tsx
index 0181f2e..27d6c83 100644
--- a/packages/use-store/src/experimental/useStore.tsx
+++ b/packages/use-store/src/experimental/useStore.tsx
@@ -101,6 +101,11 @@ export function StoreProvider({ children }: { children: React.ReactNode }) {
);
}
+type HookState = {
+ value: T;
+ selector: (state: S) => T;
+};
+
/**
* Tearing-resistant hook for consuming application state locally within a
* component (without prop drilling or putting state in context).
@@ -147,12 +152,6 @@ export function useStoreSelector(
"useStoreSelector does not currently support dynamic stores",
);
}
- const previousSelectorRef = useRef(selector);
- if (selector !== previousSelectorRef.current) {
- throw new Error(
- "useStoreSelector does not currently support dynamic selectors",
- );
- }
// Counterintuitively we initially render with the transition/head state
// instead of the committed state. This is required in order for us to
@@ -166,7 +165,19 @@ export function useStoreSelector(
// Instead we must initially render with the transition state and then
// trigger a sync fixup setState in the useLayoutEffect if we are mounting
// sync and thus should be showing the committed state.
- const [state, setState] = useState(() => selector(store.getState()));
+ //
+ // We also track the selector used for each state so that we can determine if
+ // the selector has changed since our last updated.
+ const [hookState, setState] = useState>(() => ({
+ value: selector(store.getState()),
+ selector,
+ }));
+
+ // If we have a new selector, we try to derive a new value during render. If
+ // the mount was sync, we'll apply a fixup in useLayoutEffect, just like we do
+ // on mount.
+ const selectorChange = hookState.selector !== selector;
+ const state = selectorChange ? selector(store.getState()) : hookState.value;
useLayoutEffect(() => {
// Ensure our store is managed by the tracker.
@@ -174,6 +185,18 @@ export function useStoreSelector(
const mountState = selector(store.getState());
const mountCommittedState = selector(store.getCommittedState());
+ // Helper to ensure we preserve object identity if neither state nor selector has changed.
+ function setHookState(value: T) {
+ setState((prev) => {
+ // If nothing has changed...
+ if (prev.value === value && prev.selector === selector) {
+ // Preserve object identity.
+ return prev;
+ }
+ return { value, selector };
+ });
+ }
+
// If we are mounting as part of a sync update mid transition, our initial
// render value was wrong and we must trigger a sync fixup update.
// Similarly, if a sync state update was triggered between the moment we
@@ -183,7 +206,7 @@ export function useStoreSelector(
// Both of these cases manifest as our initial render state not matching
// the currently committed state.
if (state !== mountCommittedState) {
- setState(mountCommittedState);
+ setHookState(mountCommittedState);
}
// If we mounted mid-transition, and that transition is still ongoing, we
@@ -200,20 +223,21 @@ export function useStoreSelector(
// while we were mounting resolves, it will also include rerendering
// this component to reflect the new state.
startTransition(() => {
- setState(mountState);
+ setHookState(mountState);
});
}
+
const unsubscribe = store.subscribe(() => {
- const state = store.getState();
- setState(selector(state));
+ setHookState(selector(store.getState()));
});
return () => {
unsubscribe();
storeManager.removeStore(store);
};
- // We intentionally ignore `state` since we only care about its value on mount
+ // We intentionally ignore `state` since we only care about its value on
+ // mount or when the selector changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
+ }, [selector]);
return state;
}