Skip to content

Commit 9501e12

Browse files
committed
docs(readme): update
1 parent 5dfd1bd commit 9501e12

File tree

3 files changed

+178
-21
lines changed

3 files changed

+178
-21
lines changed

README.md

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ const App = () => {
9292
| `maxHistory` | number | The maximum number of history to keep | 10 |
9393
| `initialPatches` | TravelPatches | The initial patches | {patches: [],inversePatches: []} |
9494
| `initialPosition` | number | The initial position of the state | 0 |
95-
| `autoArchive` | boolean | Auto archive the state | true |
95+
| `autoArchive` | boolean | Auto archive the state (see [Archive Mode](#archive-mode) for details) | true |
9696
| `enableAutoFreeze` | boolean | Enable auto freeze the state, [view more](https://github.com/unadlib/mutative?tab=readme-ov-file#createstate-fn-options) | false |
9797
| `strict` | boolean | Enable strict mode, [view more](https://github.com/unadlib/mutative?tab=readme-ov-file#createstate-fn-options) | false |
9898
| `mark` | Mark<O, F>[] | The mark function , [view more](https://github.com/unadlib/mutative?tab=readme-ov-file#createstate-fn-options) | () => void |
@@ -115,8 +115,71 @@ const App = () => {
115115
| `controls.go` | (nextPosition: number) => void | Go to the specific position of the state |
116116
| `controls.archive` | () => void | Archive the current state(the `autoArchive` options should be `false`) |
117117

118-
Note:
119-
> **Important**: ⚠️⚠️⚠️ `setState` can only be called once within the same synchronous call stack (for example, inside a single event handler). Subsequent calls throw an error so each undo step maps to exactly one update. Batch multiple mutations inside one updater callback (mutating the draft) or finish all updates before calling `archive()` when `autoArchive` is disabled.
118+
### Archive Mode
119+
120+
`use-travel` provides two archive modes to control how state changes are recorded in history:
121+
122+
#### Auto Archive Mode (default: `autoArchive: true`)
123+
124+
In auto archive mode, every `setState` call is automatically recorded as a separate history entry. This is the simplest mode and suitable for most use cases.
125+
126+
```jsx
127+
const [state, setState, controls] = useTravel({ count: 0 });
128+
// or explicitly: useTravel({ count: 0 }, { autoArchive: true })
129+
130+
// Each setState creates a new history entry
131+
setState({ count: 1 }); // History: [0, 1]
132+
// ... user clicks another button
133+
setState({ count: 2 }); // History: [0, 1, 2]
134+
// ... user clicks another button
135+
setState({ count: 3 }); // History: [0, 1, 2, 3]
136+
137+
controls.back(); // Go back to count: 2
138+
```
139+
140+
#### Manual Archive Mode (`autoArchive: false`)
141+
142+
In manual archive mode, you control when state changes are recorded to history using the `archive()` function. This is useful when you want to group multiple state changes into a single undo/redo step.
143+
144+
**Use Case 1: Batch multiple changes into one history entry**
145+
146+
```jsx
147+
const [state, setState, controls] = useTravel({ count: 0 }, {
148+
autoArchive: false
149+
});
150+
151+
// Multiple setState calls across different renders
152+
setState({ count: 1 }); // Temporary change (not in history yet)
153+
// ... user clicks another button
154+
setState({ count: 2 }); // Temporary change (not in history yet)
155+
// ... user clicks another button
156+
setState({ count: 3 }); // Temporary change (not in history yet)
157+
158+
// Commit all changes as a single history entry
159+
controls.archive(); // History: [0, 3]
160+
161+
// Now undo will go back to 0, not 2 or 1
162+
controls.back(); // Back to 0
163+
```
164+
165+
**Use Case 2: Explicit commit after a single change**
166+
167+
```jsx
168+
function handleSave() {
169+
setState((draft) => {
170+
draft.count += 1;
171+
});
172+
controls.archive(); // Commit immediately
173+
}
174+
```
175+
176+
The key difference:
177+
- **Auto archive**: Each `setState` = one undo step
178+
- **Manual archive**: `archive()` call = one undo step (can include multiple `setState` calls)
179+
180+
### Important Notes
181+
182+
> **⚠️ setState Restriction**: `setState` can only be called **once** within the same synchronous call stack (e.g., inside a single event handler). This ensures predictable undo/redo behavior where each history entry represents a clear, atomic change.
120183
121184
```jsx
122185
const App = () => {
@@ -126,20 +189,20 @@ const App = () => {
126189
<div>{state.count}</div>
127190
<button
128191
onClick={() => {
129-
// use-travel is not support batch setState calls, so you should use one setState call to update the state
192+
// ❌ Multiple setState calls in the same event handler
130193
setState((draft) => {
131194
draft.count += 1;
132195
});
133196
setState((draft) => {
134197
draft.todo.push({ id: 1, text: 'Buy' });
135198
});
136-
// This will throw an error, because setState can only be called once within the same synchronous call stack
199+
// This will throw: "setState cannot be called multiple times in the same render cycle"
137200

201+
// ✅ Correct: Batch all changes in a single setState
138202
setState((draft) => {
139203
draft.count += 1;
140204
draft.todo.push({ id: 1, text: 'Buy' });
141205
});
142-
// ✅ This will work
143206
}}
144207
>
145208
Update
@@ -149,9 +212,11 @@ const App = () => {
149212
};
150213
```
151214

152-
> `TravelPatches` is the type of patches history, it includes `patches` and `inversePatches`.
215+
> **Note**: With `autoArchive: false`, you can call `setState` once per event handler across multiple renders, then call `archive()` whenever you want to commit those changes to history.
216+
217+
### Persistence
153218

154-
> If you want to control the state travel manually, you can set the `autoArchive` option to `false`, and use the `controls.archive` function to archive the state.
219+
> `TravelPatches` is the type of patches history, it includes `patches` and `inversePatches`.
155220
156221
> If you want to persist the state, you can use `state`/`controls.patches`/`controls.position` to save the travel history. Then, read the persistent data as `initialState`, `initialPatches`, and `initialPosition` when initializing the state, like this:
157222

docs/README.md

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ const App = () => {
9696
| `maxHistory` | number | The maximum number of history to keep | 10 |
9797
| `initialPatches` | TravelPatches | The initial patches | {patches: [],inversePatches: []} |
9898
| `initialPosition` | number | The initial position of the state | 0 |
99-
| `autoArchive` | boolean | Auto archive the state | true |
99+
| `autoArchive` | boolean | Auto archive the state (see [Archive Mode](#archive-mode) for details) | true |
100100
| `enableAutoFreeze` | boolean | Enable auto freeze the state, [view more](https://github.com/unadlib/mutative?tab=readme-ov-file#createstate-fn-options) | false |
101101
| `strict` | boolean | Enable strict mode, [view more](https://github.com/unadlib/mutative?tab=readme-ov-file#createstate-fn-options) | false |
102102
| `mark` | Mark<O, F>[] | The mark function , [view more](https://github.com/unadlib/mutative?tab=readme-ov-file#createstate-fn-options) | () => void |
@@ -119,8 +119,71 @@ const App = () => {
119119
| `controls.go` | (nextPosition: number) => void | Go to the specific position of the state |
120120
| `controls.archive` | () => void | Archive the current state(the `autoArchive` options should be `false`) |
121121

122-
Note:
123-
> **Important**: ⚠️⚠️⚠️ `setState` can only be called once within the same synchronous call stack (for example, inside a single event handler). Subsequent calls throw an error so each undo step maps to exactly one update. Batch multiple mutations inside one updater callback (mutating the draft) or finish all updates before calling `archive()` when `autoArchive` is disabled.
122+
### Archive Mode
123+
124+
`use-travel` provides two archive modes to control how state changes are recorded in history:
125+
126+
#### Auto Archive Mode (default: `autoArchive: true`)
127+
128+
In auto archive mode, every `setState` call is automatically recorded as a separate history entry. This is the simplest mode and suitable for most use cases.
129+
130+
```jsx
131+
const [state, setState, controls] = useTravel({ count: 0 });
132+
// or explicitly: useTravel({ count: 0 }, { autoArchive: true })
133+
134+
// Each setState creates a new history entry
135+
setState({ count: 1 }); // History: [0, 1]
136+
// ... user clicks another button
137+
setState({ count: 2 }); // History: [0, 1, 2]
138+
// ... user clicks another button
139+
setState({ count: 3 }); // History: [0, 1, 2, 3]
140+
141+
controls.back(); // Go back to count: 2
142+
```
143+
144+
#### Manual Archive Mode (`autoArchive: false`)
145+
146+
In manual archive mode, you control when state changes are recorded to history using the `archive()` function. This is useful when you want to group multiple state changes into a single undo/redo step.
147+
148+
**Use Case 1: Batch multiple changes into one history entry**
149+
150+
```jsx
151+
const [state, setState, controls] = useTravel({ count: 0 }, {
152+
autoArchive: false
153+
});
154+
155+
// Multiple setState calls across different renders
156+
setState({ count: 1 }); // Temporary change (not in history yet)
157+
// ... user clicks another button
158+
setState({ count: 2 }); // Temporary change (not in history yet)
159+
// ... user clicks another button
160+
setState({ count: 3 }); // Temporary change (not in history yet)
161+
162+
// Commit all changes as a single history entry
163+
controls.archive(); // History: [0, 3]
164+
165+
// Now undo will go back to 0, not 2 or 1
166+
controls.back(); // Back to 0
167+
```
168+
169+
**Use Case 2: Explicit commit after a single change**
170+
171+
```jsx
172+
function handleSave() {
173+
setState((draft) => {
174+
draft.count += 1;
175+
});
176+
controls.archive(); // Commit immediately
177+
}
178+
```
179+
180+
The key difference:
181+
- **Auto archive**: Each `setState` = one undo step
182+
- **Manual archive**: `archive()` call = one undo step (can include multiple `setState` calls)
183+
184+
### Important Notes
185+
186+
> **⚠️ setState Restriction**: `setState` can only be called **once** within the same synchronous call stack (e.g., inside a single event handler). This ensures predictable undo/redo behavior where each history entry represents a clear, atomic change.
124187
125188
```jsx
126189
const App = () => {
@@ -130,20 +193,20 @@ const App = () => {
130193
<div>{state.count}</div>
131194
<button
132195
onClick={() => {
133-
// use-travel is not support batch setState calls, so you should use one setState call to update the state
196+
// ❌ Multiple setState calls in the same event handler
134197
setState((draft) => {
135198
draft.count += 1;
136199
});
137200
setState((draft) => {
138201
draft.todo.push({ id: 1, text: 'Buy' });
139202
});
140-
// This will throw an error, because setState can only be called once within the same synchronous call stack
203+
// This will throw: "setState cannot be called multiple times in the same render cycle"
141204

205+
// ✅ Correct: Batch all changes in a single setState
142206
setState((draft) => {
143207
draft.count += 1;
144208
draft.todo.push({ id: 1, text: 'Buy' });
145209
});
146-
// ✅ This will work
147210
}}
148211
>
149212
Update
@@ -153,9 +216,11 @@ const App = () => {
153216
};
154217
```
155218

156-
> `TravelPatches` is the type of patches history, it includes `patches` and `inversePatches`.
219+
> **Note**: With `autoArchive: false`, you can call `setState` once per event handler across multiple renders, then call `archive()` whenever you want to commit those changes to history.
220+
221+
### Persistence
157222

158-
> If you want to control the state travel manually, you can set the `autoArchive` option to `false`, and use the `controls.archive` function to archive the state.
223+
> `TravelPatches` is the type of patches history, it includes `patches` and `inversePatches`.
159224
160225
> If you want to persist the state, you can use `state`/`controls.patches`/`controls.position` to save the travel history. Then, read the persistent data as `initialState`, `initialPatches`, and `initialPosition` when initializing the state, like this:
161226

src/index.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -203,11 +203,21 @@ export function useTravel<S, F extends boolean, A extends boolean>(
203203
useRefState<TravelPatches>(() => cloneTravelPatches(initialPatches));
204204
const [state, setState] = useState(initialState);
205205

206-
// Track if setState has been called in the current render cycle
206+
// Track if setState has been called in the current render cycle.
207+
// This prevents multiple setState calls within the same synchronous execution,
208+
// which could make undo/redo behavior unpredictable for users.
207209
const setStateCalledInRender = useRef(false);
210+
211+
// Store the pending state when setState is called.
212+
// This is used by archive() to access the latest state when it's called
213+
// immediately after setState in the same synchronous execution (before React re-renders).
214+
// For archive() calls in later render cycles, this will be null and archive() will use
215+
// the committed state instead.
208216
const pendingStateRef = useRef<S | null>(null);
209217

210-
// Reset the flag at the start of each render cycle
218+
// Reset the flags at the start of each render cycle.
219+
// This allows setState to be called again in the next render cycle,
220+
// and ensures pendingStateRef is cleared after React commits the state update.
211221
useEffect(() => {
212222
setStateCalledInRender.current = false;
213223
pendingStateRef.current = null;
@@ -310,25 +320,42 @@ export function useTravel<S, F extends boolean, A extends boolean>(
310320
}
311321
const currentTempPatches = tempPatchesRef.current;
312322
if (!currentTempPatches.patches.length) return;
323+
313324
setAllPatches((allPatchesDraft) => {
314-
// All patches will be merged, it helps to minimize the patch structure
315-
// Use pendingStateRef if setState was just called, otherwise use current state
325+
// Archive commits all temporary state changes to history as a single entry.
326+
// This merges all patches since the last archive, minimizing the patch structure.
327+
//
328+
// State selection strategy:
329+
// 1. If archive() is called immediately after setState() in the same synchronous execution,
330+
// use pendingStateRef.current (the just-updated state before React re-renders)
331+
// 2. If archive() is called in a later render cycle (after one or more setState calls),
332+
// use state (which contains all committed changes from previous renders)
333+
//
334+
// Note: setAllPatches callback executes synchronously, so pendingStateRef is still valid
335+
// when archive() is called in the same event loop as setState().
336+
const stateToUse = (pendingStateRef.current ?? state) as object;
337+
316338
const [, patches, inversePatches] = create(
317-
(pendingStateRef.current ?? state) as object,
339+
stateToUse,
318340
(draft) =>
319341
apply(draft, currentTempPatches.inversePatches.flat().reverse()),
320342
{
321343
enablePatches: true,
322344
}
323345
);
346+
324347
allPatchesDraft.patches.push(inversePatches);
325348
allPatchesDraft.inversePatches.push(patches);
349+
350+
// Respect maxHistory limit
326351
if (maxHistory < allPatchesDraft.patches.length) {
327352
allPatchesDraft.patches = allPatchesDraft.patches.slice(-maxHistory);
328353
allPatchesDraft.inversePatches =
329354
allPatchesDraft.inversePatches.slice(-maxHistory);
330355
}
331356
});
357+
358+
// Clear temporary patches after archiving
332359
setTempPatches((tempPatchesDraft) => {
333360
tempPatchesDraft.patches.length = 0;
334361
tempPatchesDraft.inversePatches.length = 0;

0 commit comments

Comments
 (0)