Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Client Data Support #8173

Merged
merged 13 commits into from
Dec 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/client-data.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@remix-run/dev": minor
"@remix-run/react": minor
"@remix-run/server-runtime": minor
"@remix-run/testing": minor
---

Add support for `clientLoader`/`clientAction`/`HydrateFallback` route exports ([RFC](https://github.com/remix-run/remix/discussions/7634)).

Remix now supports loaders/actions that run on the client (in addition to, or instead of the loader/action that runs on the server). While we still recommend server loaders/actions for the majority of your data needs in a Remix app - these provide some levers you can pull for more advanced use-cases such as:

- Leveraging a data source local to the browser (i.e., `localStorage`)
- Managing a client-side cache of server data (like `IndexedDB`)
- Bypassing the Remix server in a BFF setup nd hitting your API directly from the browser
- Migrating a React Router SPA to a Remix application

By default, `clientLoader` will not run on hydration, and will only run on subsequent client side navigations.

If you wish to run your client loader on hydration, you can set `clientLoader.hydrate=true` to force Remix to execute it on initial page load. Keep in mind that Remix will still SSR your route component so you should ensure that there is no new _required_ data being added by your `clientLoader`.

If your `clientLoader` neds to run on hydration and adds data you require to render the route component, you can export a `HydrateFallback` component that will render during SSR, and then your route component will not render until the `clientLoader` has executed on hydration.

`clientAction1` is simpler than `clientLoader` because it has no hydration use-cases. `clientAction` will only run on client-side navigations.

For more information, please refer to the [`clientLoader`](https://remix.run/route/client-loader) and [`clientAction`](https://remix.run/route/client-action) documentation.
8 changes: 8 additions & 0 deletions .changeset/data-function-args.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@remix-run/server-runtime": minor
"@remix-run/server-node": minor
"@remix-run/server-cloudflare": minor
"@remix-run/server-deno": minor
---

Deprecate `DataFunctionArgs` in favor of `LoaderFunctionArgs`/`ActionFunctionArgs`. This is aimed at keeping the types aligned across server/client loaders/actions now that `clientLoader`/`clientActon` functions have `serverLoader`/`serverAction` parameters which differentiate `ClientLoaderFunctionArgs`/`ClientActionFunctionArgs`.
12 changes: 10 additions & 2 deletions docs/file-conventions/entry.server.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ You can export an optional `handleDataRequest` function that will allow you to m
```tsx
export function handleDataRequest(
response: Response,
{ request, params, context }: DataFunctionArgs
{
request,
params,
context,
}: LoaderFunctionArgs | ActionFunctionArgs
) {
response.headers.set("X-Custom-Header", "value");
return response;
Expand All @@ -30,7 +34,11 @@ By default, Remix will log encountered server-side errors to the console. If you
```tsx
export function handleError(
error: unknown,
{ request, params, context }: DataFunctionArgs
{
request,
params,
context,
}: LoaderFunctionArgs | ActionFunctionArgs
) {
if (!request.signal.aborted) {
sendErrorToErrorReportingService(error);
Expand Down
46 changes: 46 additions & 0 deletions docs/route/client-action.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
title: clientAction
---

# `clientAction`

In addition to (or in place of) your [`action`][action], you may define a `clientAction` function that will execute on the client.

Each route can define a `clientAction` function that handles mutations:

```tsx
export const clientAction = async ({
request,
params,
serverAction,
}: ClientActionFunctionArgs) => {
invalidateClientSideCache();
const data = await serverAction();
return data;
};
```

This function is only ever run on the client, and can 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
- To facilitate a migration from React Router

## Arguments

### `params`

This function receives the same [`params`][action-params] argument as an [`action`][action].

### `request`

This function receives the same [`request`][action-request] argument as an [`action`][action].

### `serverAction`

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

[action]: ./action
[action-params]: ./loader#params
[action-request]: ./loader#request
[fetch]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
121 changes: 121 additions & 0 deletions docs/route/client-loader.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
---
title: clientLoader
---

# `clientLoader`

In addition to (or in place of) your [`loader`][loader], you may define a `clientLoader` function that will execute on the client.

Each route can define a `clientLoader` function that provides data to the route when rendering:

```tsx
export const clientLoader = async ({
request,
params,
serverLoader,
}: ClientLoaderFunctionArgs) => {
// call the server loader
const serverData = await serverLoader();
// And/or fetch data on the client
const data = getDataFromClient();
// Return the data to expose through useLoaderData()
return data;
};
```

This function is only ever run on the client, and can 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
- 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
- To further augment data loaded from the server
- I.e., loading user-specific preferences from `localStorage`
- To facilitate a migration from React Router

## Hydration Behavior

By default, `clientLoader` **will not** execute for the route during initial hydration. This is for the primary (and simpler) use-case where the `clientLoader` does not change the shape of the server `loader` data and is just an optimization on subsequent client side navigations (to read from a cache or hit an API directly).

```tsx
export async function loader() {
// During SSR, we talk to the DB directly
const data = getServerDataFromDb();
return json(data);
}

export async function clientLoader() {
// During client-side navigations, we hit our exposed API endpoints directly
const data = await fetchDataFromApi();
return data;
}

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

### `clientLoader.hydrate`

If you need to run your `clientLoader` on hydration, you can opt-into that by setting `clientLoader.hydrate=true`. This will tell Remix that it needs to run the `clientLoader` on hydration. A common use-case for this is to prime a client-side cache with the data loaded on the server:

```tsx
export async function loader() {
const data = await getDataFromDB();
return json(data);
}

let isInitialHydration = true;
export async function clientLoader({ serverLoader }) {
if (isInitialHydration) {
isInitialHydration = false;
// This will resolve with the hydrated server data, it won't fetch()
const serverData = await serverLoader();
cache.set(cacheKey, serverData);
return serverData;
}

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

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

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

<docs-info>If a route exports a `clientLoader` and does not export a server `loader`, then `clientLoader.hydrate` is automatically treated as `true` since there is no server data to SSR with. Therefore, we always need to run the `clientLoader` on hydration before rendering the route component.</docs-info>

### HydrateFallback

If you need to avoid rendering your default route component during SSR because you have data that must come from a `clientLoader`, you can export a [`HydrateFallback`][hydratefallback] component from your route that will be rendered during SSR, and only once the clientLoader runs on hydration will your router component be rendered.

## Arguments

### `params`

This function receives the same [`params`][loader-params] argument as a [`loader`][loader].

### `request`

This function receives the same [`request`][loader-request] argument as a [`loader`][loader].

### `serverLoader`

`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`).

[loader]: ./loader
[loader-params]: ./loader#params
[loader-request]: ./loader#request
[hydratefallback]: ./hydrate-fallback
[bff]: ../guides/bff
[fetch]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
51 changes: 51 additions & 0 deletions docs/route/hydrate-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
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>

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-case for this is augmenting your server data with client-side data, such as saved user preferences:

```tsx
export async function loader() {
const data = getServerData();
return json(data);
}

export async function clientLoader({
request,
params,
serverLoader,
}: ClientLoaderFunctionArgs) {
const [serverData, preferences] = await Promise.all([
serverLoader(),
getUserPreferences(),
]);
return {
...serverData,
preferences,
};
}
clientLoader.hydrate = true;

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

export default function Component() {
const data = useLoaderData<typeof clientLoader>();
if (data.preferences.display === "list") {
return <ListView items={data.items} />;
} else {
return <GridView items={data.items} />;
}
}
```

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()`).

[hydrate-true]: ./client-loader#clientloaderhydrate
2 changes: 1 addition & 1 deletion docs/route/loader.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ title: loader

<docs-success>Watch the <a href="https://www.youtube.com/playlist?list=PLXoynULbYuEDG2wBFSZ66b85EIspy3fy6">📼 Remix Single</a>: <a href="https://www.youtube.com/watch?v=NXqEP_PsPNc&list=PLXoynULbYuEDG2wBFSZ66b85EIspy3fy6">Loading data into components</a></docs-success>

Each route can define a "loader" function that provides data to the route when rendering.
Each route can define a `loader` function that provides data to the route when rendering.

```tsx
import { json } from "@remix-run/node"; // or cloudflare/deno
Expand Down
Loading