Skip to content

Commit

Permalink
feat: add "context" prop to Outlet (#8461)
Browse files Browse the repository at this point in the history
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
  • Loading branch information
mcansh and kentcdodds committed Dec 9, 2021
1 parent 5730e28 commit 8f63699
Show file tree
Hide file tree
Showing 6 changed files with 223 additions and 11 deletions.
1 change: 1 addition & 0 deletions contributors.yml
Expand Up @@ -3,6 +3,7 @@
- hongji00
- JakubDrozd
- jonkoops
- kentcdodds
- kkirsche
- markivancho
- mcansh
Expand Down
75 changes: 74 additions & 1 deletion docs/api.md
Expand Up @@ -509,7 +509,12 @@ class LoginForm extends React.Component {
<summary>Type declaration</summary>

```tsx
declare function Outlet(): React.ReactElement | null;
interface OutletProps {
context?: unknown;
}
declare function Outlet(
props: OutletProps
): React.ReactElement | null;
```

</details>
Expand Down Expand Up @@ -545,6 +550,74 @@ function App() {
}
```

### `useOutletContext`

<details>
<summary>Type declaration</summary>

```tsx
declare function useOutletContext<
Context = unknown
>(): Context;
```

</details>

Often parent routes manage state or other values you want shared with child routes. You can create your own [context provider](https://reactjs.org/docs/context.html) if you like, but this is such a common situation that it's built-into `<Outlet />`:

```tsx lines=[3]
function Parent() {
const [count, setCount] = React.useState(0);
return <Outlet context={[count, setCount]} />;
}
```

```tsx lines=[2]
function Child() {
const [count, setCount] = useOutletContext();
const increment = () => setCount(c => c + 1);
return <button onClick={increment}>{count}</button>;
}
```

If you're using TypeScript, we recommend the parent component provide a custom hook for accessing the context value. This makes it easier for consumers to get nice typings, control consumers, and know who's consuming the context value. Here's a more realistic example:

```tsx filename=src/routes/dashboard.tsx lines=[12,17-19]
import * as React from "react";
import type { User } from "./types";

type ContextType = { user: User | null };

export default function Dashboard() {
const [user, setUser] = React.useState<User | null>(null);

return (
<div>
<h1>Dashboard</h1>
<Outlet context={user} />
</div>
);
}

export function useUser() {
return useOutletContext<ContextType>();
}
```

```tsx filename=src/routes/dashboard/messages.tsx lines=[1,4]
import { useUser } from "../dashboard";

export default function DashboardMessages() {
const user = useUser();
return (
<div>
<h2>Messages</h2>
<p>Hello, {user.name}!</p>
</div>
);
}
```

### `<Router>`

<details>
Expand Down
6 changes: 4 additions & 2 deletions packages/react-router-dom/index.tsx
Expand Up @@ -23,7 +23,8 @@ import {
useOutlet,
useParams,
useResolvedPath,
useRoutes
useRoutes,
useOutletContext
} from "react-router";
import type { To } from "react-router";

Expand Down Expand Up @@ -71,7 +72,8 @@ export {
useOutlet,
useParams,
useResolvedPath,
useRoutes
useRoutes,
useOutletContext
};

export type {
Expand Down
6 changes: 4 additions & 2 deletions packages/react-router-native/index.tsx
Expand Up @@ -30,7 +30,8 @@ import {
useOutlet,
useParams,
useResolvedPath,
useRoutes
useRoutes,
useOutletContext
} from "react-router";
import type { To } from "react-router";

Expand Down Expand Up @@ -63,7 +64,8 @@ export {
useOutlet,
useParams,
useResolvedPath,
useRoutes
useRoutes,
useOutletContext
};

export type {
Expand Down
120 changes: 119 additions & 1 deletion packages/react-router/__tests__/useOutlet-test.tsx
@@ -1,6 +1,12 @@
import * as React from "react";
import * as TestRenderer from "react-test-renderer";
import { MemoryRouter, Routes, Route, useOutlet } from "react-router";
import {
MemoryRouter,
Routes,
Route,
useOutlet,
useOutletContext
} from "react-router";

describe("useOutlet", () => {
describe("when there is no child route", () => {
Expand Down Expand Up @@ -50,4 +56,116 @@ describe("useOutlet", () => {
`);
});
});

describe("when there is no context", () => {
it("returns null", () => {
function Users() {
return useOutlet();
}

function Profile() {
let outletContext = useOutletContext();

return (
<div>
<h1>Profile</h1>
<pre>{outletContext}</pre>
</div>
);
}

let renderer: TestRenderer.ReactTestRenderer;
TestRenderer.act(() => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/users/profile"]}>
<Routes>
<Route path="users" element={<Users />}>
<Route path="profile" element={<Profile />} />
</Route>
</Routes>
</MemoryRouter>
);
});

expect(renderer.toJSON()).toMatchInlineSnapshot(`
<div>
<h1>
Profile
</h1>
<pre />
</div>
`);
});
});

describe("when there is context", () => {
it("returns the context", () => {
function Users() {
return useOutlet([
"Mary",
"Jane",
"Michael",
"Bert",
"Winifred",
"George"
]);
}

function Profile() {
let outletContext = useOutletContext<string[]>();

return (
<div>
<h1>Profile</h1>
<ul>
{outletContext.map(name => (
<li key={name}>{name}</li>
))}
</ul>
</div>
);
}

let renderer: TestRenderer.ReactTestRenderer;
TestRenderer.act(() => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/users/profile"]}>
<Routes>
<Route path="users" element={<Users />}>
<Route path="profile" element={<Profile />} />
</Route>
</Routes>
</MemoryRouter>
);
});

expect(renderer.toJSON()).toMatchInlineSnapshot(`
<div>
<h1>
Profile
</h1>
<ul>
<li>
Mary
</li>
<li>
Jane
</li>
<li>
Michael
</li>
<li>
Bert
</li>
<li>
Winifred
</li>
<li>
George
</li>
</ul>
</div>
`);
});
});
});
26 changes: 21 additions & 5 deletions packages/react-router/index.tsx
Expand Up @@ -183,15 +183,17 @@ export function Navigate({ to, replace, state }: NavigateProps): null {
return null;
}

export interface OutletProps {}
export interface OutletProps {
context?: unknown;
}

/**
* Renders the child route's element, if there is one.
*
* @see https://reactrouter.com/docs/en/v6/api#outlet
*/
export function Outlet(_props: OutletProps): React.ReactElement | null {
return useOutlet();
export function Outlet(props: OutletProps): React.ReactElement | null {
return useOutlet(props.context);
}

export interface RouteProps {
Expand Down Expand Up @@ -510,14 +512,28 @@ export function useNavigate(): NavigateFunction {
return navigate;
}

const OutletContext = React.createContext<unknown>(null);

/**
* Returns the context (if provided) for the child route at this level of the route
* hierarchy.
* @see https://reactrouter.com/docs/en/v6/api#useoutletcontext
*/
export function useOutletContext<Context = unknown>(): Context {
return React.useContext(OutletContext) as Context;
}

/**
* Returns the element for the child route at this level of the route
* hierarchy. Used internally by <Outlet> to render child routes.
*
* @see https://reactrouter.com/docs/en/v6/api#useoutlet
*/
export function useOutlet(): React.ReactElement | null {
return React.useContext(RouteContext).outlet;
export function useOutlet(context?: unknown): React.ReactElement | null {
let outlet = React.useContext(RouteContext).outlet;
return (
<OutletContext.Provider value={context}>{outlet}</OutletContext.Provider>
);
}

/**
Expand Down

0 comments on commit 8f63699

Please sign in to comment.