Skip to content

Commit ee66706

Browse files
authored
feat(store): implement useTravelStore with useSyncExternalStore (#11)
1 parent 19ef162 commit ee66706

File tree

5 files changed

+216
-8
lines changed

5 files changed

+216
-8
lines changed

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,47 @@ const App = () => {
117117
| `controls.go` | (nextPosition: number) => void | Go to the specific position of the state |
118118
| `controls.archive` | () => void | Archive the current state(the `autoArchive` options should be `false`) |
119119

120+
### useTravelStore
121+
122+
When you need to manage a single `Travels` instance outside of React—e.g. to share the same undo/redo history across multiple components—create the store manually and bind it with `useTravelStore`. The hook keeps React in sync with the external store, exposes the same controls object, and rejects mutable stores to ensure React can observe updates.
123+
124+
```tsx
125+
// store.ts
126+
import { Travels } from 'travels';
127+
128+
export const travels = new Travels({ count: 0 }); // mutable: true is not supported
129+
```
130+
131+
```tsx
132+
// Counter.tsx
133+
import { useTravelStore } from 'use-travel';
134+
import { travels } from './store';
135+
136+
export function Counter() {
137+
const [state, setState, controls] = useTravelStore(travels);
138+
139+
return (
140+
<div>
141+
<span>{state.count}</span>
142+
<button
143+
onClick={() =>
144+
setState((draft) => {
145+
draft.count += 1;
146+
})
147+
}
148+
>
149+
Increment
150+
</button>
151+
<button onClick={() => controls.back()} disabled={!controls.canBack()}>
152+
Undo
153+
</button>
154+
</div>
155+
);
156+
}
157+
```
158+
159+
`useTravelStore` stays reactive even when the `Travels` instance is updated elsewhere (for example, in services or other components) and forwards manual archive helpers when the store is created with `autoArchive: false`.
160+
120161
### Archive Mode
121162

122163
`use-travel` provides two archive modes to control how state changes are recorded in history:

package.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "use-travel",
3-
"version": "1.5.2",
3+
"version": "1.6.0",
44
"description": "A React hook for state time travel with undo, redo, reset and archive functionalities.",
55
"main": "dist/index.cjs.js",
66
"unpkg": "dist/index.umd.js",
@@ -69,7 +69,7 @@
6969
"react-dom": "^18.2.0",
7070
"rimraf": "^4.4.0",
7171
"rollup": "^4.52.3",
72-
"travels": "^0.5.2",
72+
"travels": "^0.7.0",
7373
"ts-node": "^10.9.2",
7474
"tslib": "^2.8.1",
7575
"typedoc": "^0.26.11",
@@ -87,6 +87,10 @@
8787
"@types/react": "^17.0 || ^18.0 || ^19.0",
8888
"mutative": "^1.3.0",
8989
"react": "^17.0 || ^18.0 || ^19.0",
90-
"travels": "^0.5.2"
90+
"travels": "^0.7.0"
91+
},
92+
"dependencies": {
93+
"@types/use-sync-external-store": "^1.5.0",
94+
"use-sync-external-store": "^1.6.0"
9195
}
9296
}

src/index.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
PatchesOption,
1010
} from 'travels';
1111
import { Travels } from 'travels';
12+
import { useSyncExternalStore } from 'use-sync-external-store/shim';
1213

1314
export type { TravelPatches };
1415

@@ -24,7 +25,21 @@ type Result<
2425
];
2526

2627
/**
27-
* A hook to travel in the history of a state
28+
* Creates a component-scoped {@link Travels} instance with undo/redo support and returns its reactive API.
29+
*
30+
* The hook memoises the underlying `Travels` instance per component, wires it to React's lifecycle, and forces
31+
* re-renders whenever the managed state changes. Consumers receive a tuple containing the current state, a `setState`
32+
* updater that accepts either a mutative draft function or partial state, and the history controls exposed by
33+
* {@link Travels}.
34+
*
35+
* @typeParam S - Shape of the state managed by the travel store.
36+
* @typeParam F - Whether draft freezing is enabled.
37+
* @typeParam A - Whether the instance auto-archives changes; determines the controls contract.
38+
* @typeParam P - Additional patches configuration forwarded to Mutative.
39+
* @param initialState - Value used to initialise the travel store.
40+
* @param _options - Optional configuration mirrored from {@link Travels}.
41+
* @returns A tuple with the current state, typed updater, and history controls.
42+
* @throws {Error} When `setState` is invoked multiple times within the same render cycle (development-only guard).
2843
*/
2944
export function useTravel<S, F extends boolean>(
3045
initialState: S
@@ -176,3 +191,54 @@ export function useTravel<
176191

177192
return [state, cachedSetState, cachedControls] as Result<S, F, A>;
178193
}
194+
195+
/**
196+
* Subscribes to an existing {@link Travels} store and bridges it into React via `useSyncExternalStore`.
197+
*
198+
* The hook keeps React in sync with the store's state and exposes the same tuple shape as {@link useTravel}, but it
199+
* does not create or manage the store lifecycle. Mutable Travels instances are rejected because they reuse the same
200+
* state reference, which prevents React from observing updates.
201+
*
202+
* @typeParam S - Shape of the state managed by the travel store.
203+
* @typeParam F - Whether draft freezing is enabled.
204+
* @typeParam A - Whether the instance auto-archives changes; determines the controls contract.
205+
* @typeParam P - Additional patches configuration forwarded to Mutative.
206+
* @param travels - Existing {@link Travels} instance to bind to React.
207+
* @returns A tuple containing the current state, typed updater, and history controls.
208+
* @throws {Error} If the provided `Travels` instance was created with `mutable: true`.
209+
*/
210+
export function useTravelStore<
211+
S,
212+
F extends boolean,
213+
A extends boolean,
214+
P extends PatchesOption = {},
215+
>(
216+
travels: Travels<S, F, A, P>
217+
): [
218+
Value<S, F>,
219+
(updater: Updater<S>) => void,
220+
A extends false ? ManualTravelsControls<S, F, P> : TravelsControls<S, F, P>,
221+
] {
222+
const isMutable = Boolean((travels as any)?.mutable);
223+
224+
if (isMutable) {
225+
throw new Error(
226+
'useTravelStore only supports immutable Travels instances. Remove `mutable: true` or use useTravel instead.'
227+
);
228+
}
229+
const state = useSyncExternalStore(
230+
travels.subscribe.bind(travels),
231+
travels.getState.bind(travels),
232+
travels.getState.bind(travels)
233+
);
234+
const setState = useCallback(
235+
(updater: Updater<S>) => travels.setState(updater),
236+
[travels]
237+
);
238+
const controls = useMemo(() => travels.getControls(), [travels]);
239+
return [state as Value<S, F>, setState, controls] as [
240+
Value<S, F>,
241+
(updater: Updater<S>) => void,
242+
A extends false ? ManualTravelsControls<S, F, P> : TravelsControls<S, F, P>,
243+
];
244+
}

test/use-travel-store.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { act, renderHook } from '@testing-library/react';
3+
import { Travels } from 'travels';
4+
import { useTravelStore } from '../src/index';
5+
6+
describe('useTravelStore', () => {
7+
it('throws when used with a mutable Travels instance', () => {
8+
const travels = new Travels({ count: 0 }, { mutable: true });
9+
10+
expect(() =>
11+
renderHook(() => useTravelStore(travels))
12+
).toThrowError(
13+
/useTravelStore only supports immutable Travels instances/
14+
);
15+
});
16+
17+
it('syncs state and controls with an immutable Travels instance', () => {
18+
const travels = new Travels({ count: 0 });
19+
20+
const { result } = renderHook(() => useTravelStore(travels));
21+
22+
let [state, setState, controls] = result.current;
23+
expect(state).toEqual({ count: 0 });
24+
expect(typeof setState).toBe('function');
25+
expect(controls.getHistory()).toEqual(travels.getHistory());
26+
27+
act(() =>
28+
setState((draft) => {
29+
draft.count = 1;
30+
})
31+
);
32+
[state, setState, controls] = result.current;
33+
34+
expect(state).toEqual({ count: 1 });
35+
expect(travels.getState()).toEqual({ count: 1 });
36+
expect(controls.getHistory()).toEqual(travels.getHistory());
37+
38+
act(() =>
39+
travels.setState((draft) => {
40+
draft.count = 42;
41+
})
42+
);
43+
[state, setState, controls] = result.current;
44+
45+
expect(state).toEqual({ count: 42 });
46+
expect(controls.getHistory()).toEqual(travels.getHistory());
47+
});
48+
49+
it('exposes manual archive controls when autoArchive is disabled', () => {
50+
const travels = new Travels(
51+
{ todos: [] as string[] },
52+
{ autoArchive: false }
53+
);
54+
55+
const { result } = renderHook(() => useTravelStore(travels));
56+
57+
let [state, setState, controls] = result.current;
58+
const manualControls = controls as ReturnType<typeof travels.getControls>;
59+
expect(typeof (manualControls as any).archive).toBe('function');
60+
expect(typeof (manualControls as any).canArchive).toBe('function');
61+
expect((manualControls as any).canArchive()).toBe(false);
62+
63+
act(() =>
64+
setState((draft) => {
65+
draft.todos.push('todo 1');
66+
})
67+
);
68+
[state, setState, controls] = result.current;
69+
70+
const manualControlsAfterUpdate =
71+
controls as ReturnType<typeof travels.getControls>;
72+
73+
expect(state.todos).toEqual(['todo 1']);
74+
expect((manualControlsAfterUpdate as any).canArchive()).toBe(true);
75+
76+
act(() => (manualControlsAfterUpdate as any).archive());
77+
[state, setState, controls] = result.current;
78+
79+
const manualControlsAfterArchive =
80+
controls as ReturnType<typeof travels.getControls>;
81+
82+
expect((manualControlsAfterArchive as any).canArchive()).toBe(false);
83+
expect(manualControlsAfterArchive.getHistory()).toEqual(
84+
travels.getHistory()
85+
);
86+
});
87+
});

yarn.lock

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -813,6 +813,11 @@
813813
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c"
814814
integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==
815815

816+
"@types/use-sync-external-store@^1.5.0":
817+
version "1.5.0"
818+
resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz#222c28a98eb8f4f8a72c1a7e9fe6d8946eca6383"
819+
integrity sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==
820+
816821
"@typescript-eslint/eslint-plugin@^8.44.1":
817822
version "8.44.1"
818823
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz#011a2b5913d297b3d9d77f64fb78575bab01a1b3"
@@ -4387,10 +4392,10 @@ tr46@^6.0.0:
43874392
dependencies:
43884393
punycode "^2.3.1"
43894394

4390-
travels@^0.5.2:
4391-
version "0.5.2"
4392-
resolved "https://registry.yarnpkg.com/travels/-/travels-0.5.2.tgz#25d167d9feb2bce987bdb463c69bf98852456090"
4393-
integrity sha512-2cr4HJ02keoD1DrGCjhguKY2j55ySKkrFGZpZT9u264orhQd+Df6njXOUcFEHGxfoKBOwXlHK+RJ2IKlLYjTeQ==
4395+
travels@^0.7.0:
4396+
version "0.7.0"
4397+
resolved "https://registry.yarnpkg.com/travels/-/travels-0.7.0.tgz#8c6ddf2802f6cb4c59c5857cf1c92912e0f7a1ef"
4398+
integrity sha512-4MVdWct7TWVIU5jPYz6rWQmML3iVlUzXv1YBqyBO4ONpERlg3Z/PfOoUCFCXY8lzc57BsJlRvHuPKqN1WML2+Q==
43944399

43954400
trim-lines@^3.0.0:
43964401
version "3.0.1"
@@ -4600,6 +4605,11 @@ uri-js@^4.2.2:
46004605
dependencies:
46014606
punycode "^2.1.0"
46024607

4608+
use-sync-external-store@^1.6.0:
4609+
version "1.6.0"
4610+
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d"
4611+
integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==
4612+
46034613
util-deprecate@^1.0.1:
46044614
version "1.0.2"
46054615
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"

0 commit comments

Comments
 (0)