Skip to content

Commit 5d45497

Browse files
Allow useNavigate to be called from child component effects (#10394)
Co-authored-by: 42shadow42 <[email protected]>
1 parent af76d50 commit 5d45497

File tree

4 files changed

+244
-8
lines changed

4 files changed

+244
-8
lines changed

.changeset/navigate-in-effect.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-router": patch
3+
---
4+
5+
Fix detection of `useNavigate` in the render cycle by setting the `activeRef` in a layout effect, allowing the `navigate` function to be passed to child components and called in a `useEffect` there.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,10 @@
108108
"none": "45.8 kB"
109109
},
110110
"packages/react-router/dist/react-router.production.min.js": {
111-
"none": "13 kB"
111+
"none": "13.1 kB"
112112
},
113113
"packages/react-router/dist/umd/react-router.production.min.js": {
114-
"none": "15.3 kB"
114+
"none": "15.4 kB"
115115
},
116116
"packages/react-router-dom/dist/react-router-dom.production.min.js": {
117117
"none": "12 kB"

packages/react-router/__tests__/useNavigate-test.tsx

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,211 @@ describe("useNavigate", () => {
301301
);
302302
});
303303

304+
describe("navigating in effects versus render", () => {
305+
let warnSpy: jest.SpyInstance;
306+
307+
beforeEach(() => {
308+
warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
309+
});
310+
311+
afterEach(() => {
312+
warnSpy.mockRestore();
313+
});
314+
315+
describe("MemoryRouter", () => {
316+
it("does not allow navigation from the render cycle", () => {
317+
let renderer: TestRenderer.ReactTestRenderer;
318+
TestRenderer.act(() => {
319+
renderer = TestRenderer.create(
320+
<MemoryRouter>
321+
<Routes>
322+
<Route index element={<Home />} />
323+
<Route path="about" element={<h1>About</h1>} />
324+
</Routes>
325+
</MemoryRouter>
326+
);
327+
});
328+
329+
function Home() {
330+
let navigate = useNavigate();
331+
navigate("/about");
332+
return <h1>Home</h1>;
333+
}
334+
335+
// @ts-expect-error
336+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
337+
<h1>
338+
Home
339+
</h1>
340+
`);
341+
expect(warnSpy).toHaveBeenCalledWith(
342+
"You should call navigate() in a React.useEffect(), not when your component is first rendered."
343+
);
344+
});
345+
346+
it("allows navigation from effects", () => {
347+
let renderer: TestRenderer.ReactTestRenderer;
348+
TestRenderer.act(() => {
349+
renderer = TestRenderer.create(
350+
<MemoryRouter>
351+
<Routes>
352+
<Route index element={<Home />} />
353+
<Route path="about" element={<h1>About</h1>} />
354+
</Routes>
355+
</MemoryRouter>
356+
);
357+
});
358+
359+
function Home() {
360+
let navigate = useNavigate();
361+
React.useEffect(() => navigate("/about"), [navigate]);
362+
return <h1>Home</h1>;
363+
}
364+
365+
// @ts-expect-error
366+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
367+
<h1>
368+
About
369+
</h1>
370+
`);
371+
expect(warnSpy).not.toHaveBeenCalled();
372+
});
373+
374+
it("allows navigation in child useEffects", () => {
375+
let renderer: TestRenderer.ReactTestRenderer;
376+
TestRenderer.act(() => {
377+
renderer = TestRenderer.create(
378+
<MemoryRouter initialEntries={["/home"]}>
379+
<Routes>
380+
<Route path="home" element={<Parent />} />
381+
<Route path="about" element={<h1>About</h1>} />
382+
</Routes>
383+
</MemoryRouter>
384+
);
385+
});
386+
387+
function Parent() {
388+
let navigate = useNavigate();
389+
let onChildRendered = React.useCallback(
390+
() => navigate("/about"),
391+
[navigate]
392+
);
393+
return <Child onChildRendered={onChildRendered} />;
394+
}
395+
396+
function Child({ onChildRendered }) {
397+
React.useEffect(() => onChildRendered());
398+
return null;
399+
}
400+
401+
// @ts-expect-error
402+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
403+
<h1>
404+
About
405+
</h1>
406+
`);
407+
});
408+
});
409+
410+
describe("RouterProvider", () => {
411+
it("does not allow navigation from the render cycle", async () => {
412+
let router = createMemoryRouter([
413+
{
414+
index: true,
415+
Component() {
416+
let navigate = useNavigate();
417+
navigate("/about");
418+
return <h1>Home</h1>;
419+
},
420+
},
421+
{
422+
path: "about",
423+
element: <h1>About</h1>,
424+
},
425+
]);
426+
let renderer: TestRenderer.ReactTestRenderer;
427+
TestRenderer.act(() => {
428+
renderer = TestRenderer.create(<RouterProvider router={router} />);
429+
});
430+
431+
// @ts-expect-error
432+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
433+
<h1>
434+
Home
435+
</h1>
436+
`);
437+
expect(warnSpy).toHaveBeenCalledWith(
438+
"You should call navigate() in a React.useEffect(), not when your component is first rendered."
439+
);
440+
});
441+
442+
it("allows navigation from effects", () => {
443+
let router = createMemoryRouter([
444+
{
445+
index: true,
446+
Component() {
447+
let navigate = useNavigate();
448+
React.useEffect(() => navigate("/about"), [navigate]);
449+
return <h1>Home</h1>;
450+
},
451+
},
452+
{
453+
path: "about",
454+
element: <h1>About</h1>,
455+
},
456+
]);
457+
let renderer: TestRenderer.ReactTestRenderer;
458+
TestRenderer.act(() => {
459+
renderer = TestRenderer.create(<RouterProvider router={router} />);
460+
});
461+
462+
// @ts-expect-error
463+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
464+
<h1>
465+
About
466+
</h1>
467+
`);
468+
expect(warnSpy).not.toHaveBeenCalled();
469+
});
470+
471+
it("allows navigation in child useEffects", () => {
472+
let router = createMemoryRouter([
473+
{
474+
index: true,
475+
Component() {
476+
let navigate = useNavigate();
477+
let onChildRendered = React.useCallback(
478+
() => navigate("/about"),
479+
[navigate]
480+
);
481+
return <Child onChildRendered={onChildRendered} />;
482+
},
483+
},
484+
{
485+
path: "about",
486+
element: <h1>About</h1>,
487+
},
488+
]);
489+
let renderer: TestRenderer.ReactTestRenderer;
490+
TestRenderer.act(() => {
491+
renderer = TestRenderer.create(<RouterProvider router={router} />);
492+
});
493+
494+
function Child({ onChildRendered }) {
495+
React.useEffect(() => onChildRendered());
496+
return null;
497+
}
498+
499+
// @ts-expect-error
500+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
501+
<h1>
502+
About
503+
</h1>
504+
`);
505+
});
506+
});
507+
});
508+
304509
describe("with state", () => {
305510
it("adds the state to location.state", () => {
306511
function Home() {

packages/react-router/lib/hooks.tsx

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,23 @@ export interface NavigateFunction {
150150
(delta: number): void;
151151
}
152152

153+
const navigateEffectWarning =
154+
`You should call navigate() in a React.useEffect(), not when ` +
155+
`your component is first rendered.`;
156+
157+
// Mute warnings for calls to useNavigate in SSR environments
158+
function useIsomorphicLayoutEffect(
159+
cb: Parameters<typeof React.useLayoutEffect>[0]
160+
) {
161+
let isStatic = React.useContext(NavigationContext).static;
162+
if (!isStatic) {
163+
// We should be able to get rid of this once react 18.3 is released
164+
// See: https://github.com/facebook/react/pull/26395
165+
// eslint-disable-next-line react-hooks/rules-of-hooks
166+
React.useLayoutEffect(cb);
167+
}
168+
}
169+
153170
/**
154171
* Returns an imperative method for changing the location. Used by <Link>s, but
155172
* may also be used by other elements to change the location.
@@ -180,18 +197,16 @@ function useNavigateUnstable(): NavigateFunction {
180197
);
181198

182199
let activeRef = React.useRef(false);
183-
React.useEffect(() => {
200+
useIsomorphicLayoutEffect(() => {
184201
activeRef.current = true;
185202
});
186203

187204
let navigate: NavigateFunction = React.useCallback(
188205
(to: To | number, options: NavigateOptions = {}) => {
189-
warning(
190-
activeRef.current,
191-
`You should call navigate() in a React.useEffect(), not when ` +
192-
`your component is first rendered.`
193-
);
206+
warning(activeRef.current, navigateEffectWarning);
194207

208+
// Short circuit here since if this happens on first render the navigate
209+
// is useless because we haven't wired up our history listener yet
195210
if (!activeRef.current) return;
196211

197212
if (typeof to === "number") {
@@ -931,8 +946,19 @@ function useNavigateStable(): NavigateFunction {
931946
let { router } = useDataRouterContext(DataRouterHook.UseNavigateStable);
932947
let id = useCurrentRouteId(DataRouterStateHook.UseNavigateStable);
933948

949+
let activeRef = React.useRef(false);
950+
useIsomorphicLayoutEffect(() => {
951+
activeRef.current = true;
952+
});
953+
934954
let navigate: NavigateFunction = React.useCallback(
935955
(to: To | number, options: NavigateOptions = {}) => {
956+
warning(activeRef.current, navigateEffectWarning);
957+
958+
// Short circuit here since if this happens on first render the navigate
959+
// is useless because we haven't wired up our router subscriber yet
960+
if (!activeRef.current) return;
961+
936962
if (typeof to === "number") {
937963
router.navigate(to);
938964
} else {

0 commit comments

Comments
 (0)