Skip to content

Commit

Permalink
Add Client Data guide to docs (#8229)
Browse files Browse the repository at this point in the history
  • Loading branch information
brophdawg11 authored Dec 7, 2023
1 parent 005760d commit c6993cf
Show file tree
Hide file tree
Showing 4 changed files with 292 additions and 8 deletions.
241 changes: 241 additions & 0 deletions docs/guides/client-data.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
---
title: Client Data
---

# Client Data

Remix introduced support for "Client Data" ([RFC][rfc]) in [`v2.4.0`][2.4.0] which allows you to opt-into running route loaders/actions in the browser via [`clientLoader`][clientloader]/[`clientAction`][clientaction] exports from your route.

These new exports are a bit of a sharp knife and are not recommended as your _primary_ data loading/submission mechanisms - but instead give you a lever to pull on for some of the following advanced use cases:

- **Skip the Hop:** Query a data API directly from the browser, using loaders simply for SSR
- **Fullstack State:** Augment server data with client data for your full set of loader data
- **One or the Other:** Sometimes you use server loaders, sometimes you use client loaders, but not both on one route
- **Client Cache:** Cache server loader data in the client and avoid some server calls
- **Migration:** Ease your migration from React Router -> Remix SPA -> Remix SSR (once Remix supports [SPA mode][rfc-spa])

Please use these new exports with caution! If you're not careful - it's easy to get your UI out of sync. Remix out of the box tries _very_ hard to ensure that this doesn't happen - but once you take control over your own client-side cache, and potentially prevent Remix from performing it's normal server `fetch` calls - then Remix can no longer guarantee your UI remains in sync.

## Skip the Hop

When using Remix in a [BFF][bff] architecture, it may be advantageous to skip the Remix server hop and hit your backend API directly. This assumes you are able to handle authentication accordingly and are not subject to CORS issues. You can skip the Remix BFF hop as follows:

1. Load the data from server `loader` on the document load
2. Load the data from the `clientLoader` on all subsequent loads

In this scenario, we will _not_ call the `clientLoader` on hydration - and will only call it on subsequent navigations.

```tsx lines=[8,15]
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import type { ClientLoaderFunctionArgs } from "@remix-run/react";

export async function loader({
request,
}: LoaderFunctionArgs) {
const data = await fetchApiFromServer({ request }); // (1)
return json(data);
}

export async function clientLoader({
request,
}: ClientLoaderFunctionArgs) {
const data = await fetchApiFromClient({ request }); // (2)
return data;
}
```

## Fullstack State

Sometimes, you may want to leverage "Fullstack State" where some of your data comes from the server, and some of your data comes from the browser (i.e., `IndexedDB` or other browser SDKs) - but you can't render your component until you have the combined set of data. You can combine these two data sources as follows:

1. Load the partial data from server `loader` on the document load
2. Export a [`HydrateFallback`][hydratefallback] component to render during SSR because we don't yet have a full set of data
3. Set `clientLoader.hydrate = true` to load the rest of the data on hydration
4. Combine the server data with the client data in `clientLoader`

```tsx lines=[8-10,23-24,27,30]
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import type { ClientLoaderFunctionArgs } from "@remix-run/react";

export async function loader({
request,
}: LoaderFunctionArgs) {
const partialData = await getPartialDataFromDb({
request,
}); // (1)
return json(partialData);
}

export async function clientLoader({
request,
serverLoader,
}: ClientLoaderFunctionArgs) {
const [serverData, clientData] = await Promise.all([
serverLoader(),
getClientData(request),
]);
return {
...serverData, // (4)
...clientData, // (4)
};
}
clientLoader.hydrate = true; // (3)

export function HydrateFallback() {
return <p>Skeleton rendered during SSR</p>; // (2)
}

export default function Component() {
// This will always be the combined set of server + client data
const data = useLoaderData();
return <>...</>;
}
```

## One or the Other

You may want to mix and match data loading strategies in your application such that some routes only load data on the server and some routes only load data on the client. You can choose per route as follows:

1. Export a `loader` when you want to use server data
2. Export `clientLoader` and a `HydrateFallback` when you want to use client data

```tsx filename="app/routes/server-data-route.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";

export async function loader({
request,
}: LoaderFunctionArgs) {
const data = await getServerData(request);
return json(data);
}

export default function Component() {
const data = useLoaderData(); // server data
return <>...</>;
}
```

```tsx filename="app/routes/client-data-route.tsx
import type { ClientLoaderFunctionArgs } from "@remix-run/react";

export async function clientLoader({
request,
serverLoader,
}: ClientLoaderFunctionArgs) {
const [serverData, clientData] = await Promise.all([
serverLoader(),
getClientData(request),
]);
return {
...serverData, // (4)
...clientData, // (4)
};
}
// Note: you do not have to set this explicitly - it is implied if there is no `loader`
clientLoader.hydrate = true;

export function HydrateFallback() {
return <p>Skeleton rendered during SSR</p>; // (2)
}

export default function Component() {
const data = useLoaderData(); // client data
return <>...</>;
}
```

## Client Cache

Remix normally recommends using `Cache-Control` headers for caching of loader data, however you may want to use a more manual in-browser client-side cache (i.e., in-memory cache, `localStorage`). You can leverage a client-side cache to bypass certain calls to the server as follows:

1. Load the data from server `loader` on the document load
2. Set `clientLoader.hydrate = true` to prime the cache
3. Load subsequent navigations from the cache via `clientLoader`
4. Invalidate the cache in your `clientAction`

Note that since we are not exporting a `HydrateFallback` component, we will SSR the route component and then run the `clientLoader` on hydration, so it's important that your `loader` and `clientLoader` return the same data on initial load to avoid hydration errors.

```tsx lines=[14,36,42,49,56]
import type {
ActionFunctionArgs,
LoaderFunctionArgs,
} from "@remix-run/node";
import { json } from "@remix-run/node";
import type {
ClientActionFunctionArgs,
ClientLoaderFunctionArgs,
} from "@remix-run/react";

export async function loader({
request,
}: LoaderFunctionArgs) {
const data = await getDataFromDb({ request }); // (1)
return json(data);
}

export async function action({
request,
}: ActionFunctionArgs) {
await saveDataToDb({ request });
return json({ ok: true });
}

let isInitialRequest = true;

export async function clientLoader({
request,
serverLoader,
}: ClientLoaderFunctionArgs) {
const cacheKey = generateKey(request);

if (isInitialRequest) {
isInitialRequest = false;
const serverData = await serverLoader();
cache.set(cacheKey, serverData); // (2)
return serverData;
}

const cachedData = await cache.get(cacheKey);
if (cachedData) {
return cachedData; // (3)
}

const serverData = await serverLoader();
cache.set(cacheKey, serverData);
return serverData;
}
clientLoader.hydrate = true; // (2)

export async function clientAction({
request,
serverAction,
}: ClientActionFunctionArgs) {
const cacheKey = generateKey(request);
cache.delete(cacheKey); // (4)
const serverData = await serverAction();
return serverData;
}
```

## Migration

We expect to write up a separate guide for migrations once [SPA mode][rfc-spa] lands, but for now we expect that the process will be something like:

1. Introduce data patterns in your React Router SPA by moving to `createBrowserRouter`/`RouterProvider`
2. Move your SPA to use Vite to better prepare for the Remix migration
3. Incrementally move to file-based route definitions via the use of a Vite plugin (not yet provided)
4. Migrate your React Router SPA to Remix SPA mode where all current file-based `loader` function act as `clientLoader`
5. Opt out of Remix SPA mode (and into Remix SSR mode) and find/replace your `loader` functions to `clientLoader`
- You're now running an SSR app but all your data loading is still happening in the client via `clientLoader`
6. Incrementally start moving `clientLoader -> loader` to start moving data loading to the server

[rfc]: https://github.com/remix-run/remix/discussions/7634
[2.4.0]: https://github.com/remix-run/remix/blob/main/CHANGELOG.md#v240
[clientloader]: ../route/client-loader
[clientaction]: ../route/client-action
[hydratefallback]: ../route/hydrate-fallback
[rfc-spa]: https://github.com/remix-run/remix/discussions/7638
[bff]: ../guides/bff
9 changes: 8 additions & 1 deletion docs/route/client-action.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const clientAction = async ({
};
```

This function is only ever run on the client, and can used in a few ways:
This function is only ever run on the client, and can be used in a few ways:

- Instead of a server action for full-client routes
- To use alongside a `clientLoader` cache by invalidating the cache on mutations
Expand All @@ -40,7 +40,14 @@ This function receives the same [`request`][action-request] argument as an [`act

`serverAction` is an asynchronous function that makes the [fetch][fetch] call to the server `action` for this route.

See also:

- [Client Data Guide][client-data-guide]
- [clientLoader][clientloader]

[action]: ./action
[action-params]: ./loader#params
[action-request]: ./loader#request
[fetch]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
[client-data-guide]: ../guides/client-data
[clientloader]: ./client-loader
12 changes: 10 additions & 2 deletions docs/route/client-loader.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ export const clientLoader = async ({
};
```

This function is only ever run on the client, and can used in a few ways:
This function is only ever run on the client, and can be used in a few ways:

- Instead of a server action for full-client routes
- Instead of a server loader for full-client routes
- To use alongside a `clientLoader` cache by invalidating the cache on mutations
- Maintaining a client-side cache to skip calls to the server
- Bypassing the Remix [BFF][bff] hop and hitting your API directly from the client
Expand Down Expand Up @@ -80,9 +80,17 @@ This function receives the same [`request`][loader-request] argument as a [`load

`serverLoader` is an asynchronous function to get the data from the server `loader` for this route. On client-side navigations, this will make a [fetch][fetch] call to the Remix server loader. If you opt-into running your `clientLoader` on hydration, then this function will return you the data that was already loaded on the server (via `Promise.resolve`).

See also:

- [Client Data Guide][client-data-guide]
- [HydrateFallback][hydratefallback]
- [clientAction][clientaction]

[loader]: ./loader
[loader-params]: ./loader#params
[loader-request]: ./loader#request
[clientaction]: ./client-action
[hydratefallback]: ./hydrate-fallback
[bff]: ../guides/bff
[fetch]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
[client-data-guide]: ../guides/client-data
38 changes: 33 additions & 5 deletions docs/route/hydrate-fallback.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,28 @@ title: HydrateFallback

# `HydrateFallback`

<docs-info>The `HydrateFallback` component is only relevant when you are also setting [`clientLoader.hydrate=true`][hydrate-true] on a given route.</docs-info>
A `HydrateFallback` component is your way of informing Remix that you do not want to render your route component until _after_ the `clientLoader` has run on hydration. When exported, Remix will render the fallback during SSR instead of your default route component, and will render your route component client-side once the `clientLoader` completes.

When provided, a `HydrateFallback` component will be rendered during SSR instead of your default route component, because you need to run your `clientLoader` to get a complete set of loader data. The `clientLoader` will then be called on hydration and once completed, Remix will render your route component with the complete loader data.
The most common use-cases for this are client-only routes (such an in-browser canvas game) and augmenting your server data with client-side data (such as saved user preferences).

The most common use-case for this is augmenting your server data with client-side data, such as saved user preferences:
```tsx filename=routes/client-only-route.tsx
export async function clientLoader() {
const data = await loadSavedGameOrPrepareNewGame();
return data;
}
// Note clientLoader.hydrate is implied without a server loader

export function HydrateFallback() {
return <p>Loading Game...</p>;
}

export default function Component() {
const data = useLoaderData<typeof clientLoader>();
return <Game data={data} />;
}
```

```tsx
```tsx filename=routes/augmenting-server-data.tsx
export async function loader() {
const data = getServerData();
return json(data);
Expand Down Expand Up @@ -46,6 +61,19 @@ export default function Component() {
}
```

If you have multiple routes with `clientLoader.hydrate=true`, then Remix will server-render up until the highest-discovered `HydrateFallback`. You cannot render an `<Outlet/>` in a `HydrateFallback` because children routes can't be guaranteed to operate correctly since their ancestor loader data may not yet be available if they are running `clientLoader` functions on hydration (i.e., use cases such as `useRouteLoaderData()` or `useMatches()`).
There are a few nuances worth noting around the behavior of `HydrateFallback`:

- It is only relevant on initial document request and hydration, and will not be rendered on any subsequent client-side navigations
- It is only relevant when you are also setting [`clientLoader.hydrate=true`][hydrate-true] on a given route
- It is also relevant if you do have a `clientLoader` without a server `loader`, as this implies `clientLoader.hydrate=true` since there is otherwise no loader data at all to return from `useLoaderData`
- Even if you do not specify a `HydrateFallback` in this case, Remix will not render your route component and will bubble up to any ancestor `HydrateFallback` component
- This is to ensure that `useLoaderData` remains "happy-path"
- Without a server `loader`, `useLoaderData` would return `undefined` in any rendered route components
- You cannot render an `<Outlet/>` in a `HydrateFallback` because children routes can't be guaranteed to operate correctly since their ancestor loader data may not yet be available if they are running `clientLoader` functions on hydration (i.e., use cases such as `useRouteLoaderData()` or `useMatches()`)

See also:

- [clientLoader][clientloader]

[hydrate-true]: ./client-loader#clientloaderhydrate
[clientloader]: ./client-loader

0 comments on commit c6993cf

Please sign in to comment.