NextJS foot guns: Over-reliance on Client Side State
Posted on Oct 10th, 2024 in NextJS
This article builds on “Architecting NextJS Applications for Better Performance,” focusing on optimizing state management in NextJS projects. It explores the pitfalls of overusing Context providers and client-side state, which can bloat bundles and hurt performance. By strategically identifying which parts of your app should be client or server components, you can leverage NextJS’s capabilities to build faster, more efficient applications.
Last week, my post, Architecting NextJS Applications for Better Performance, dove into how to better layout your project to take advantage of Server and Client Components. I explained in depth how to interleave them, and the impact this has to your overall application performance.
One question that kept popping up was, well what about Context
providers? Should we always avoid using Context
s everywhere? Should we only wrap leaf nodes in Context Providers
? The answer is of course not, there is no hard and fast rule on what needs to be a client component and what doesn’t.
Summary
- Over relying on client side state management bloats client side bundles, dragging down performance with it.
- Client Side state management can be a little insidious because their hooks force components to be client components, but using the hooks is natural to veteran React developers.
- By focusing on identifying what must be, and what shouldn’t be a client component, we can build more performant applications by minimizing the work our clients need to do.
Client Side State and Sever Side Render applications
With a traditional react SPA, its natural to perform all of the data fetching inside of a client side state management library, and use the same library to render the product title, details, price, models, etc even if this data is static. Its easy to extend this pattern to a NextJS application as well. All to often, what I see happen is at the root of the of the layout, a fetch to create the page happens, and then that data is passed into the root context provider.
Take a look at the following code samples from Apollo’s setting up SSR:
// apollo-wrapper.tsx
"use client";
import { HttpLink } from "@apollo/client";
import {
ApolloNextAppProvider,
ApolloClient,
InMemoryCache,
} from "@apollo/experimental-nextjs-app-support";
// have a function to create a client for you
function makeClient() {
const httpLink = new HttpLink({
// this needs to be an absolute url, as relative urls cannot be used in SSR
uri: "https://example.com/api/graphql",
// you can disable result caching here if you want to
// (this does not work if you are rendering your page with `export const dynamic = "force-static"`)
fetchOptions: { cache: "no-store" },
// you can override the default `fetchOptions` on a per query basis
// via the `context` property on the options passed as a second argument
// to an Apollo Client data fetching hook, e.g.:
// const { data } = useSuspenseQuery(MY_QUERY, { context: { fetchOptions: { cache: "force-cache" }}});
});
// use the `ApolloClient` from "@apollo/experimental-nextjs-app-support"
return new ApolloClient({
// use the `InMemoryCache` from "@apollo/experimental-nextjs-app-support"
cache: new InMemoryCache(),
link: httpLink,
});
}
// you need to create a component to wrap your app in
export function ApolloWrapper({ children }: React.PropsWithChildren) {
return (
<ApolloNextAppProvider makeClient={makeClient}>
{children}
</ApolloNextAppProvider>
);
}
"use client";
import { useSuspenseQuery } from "@apollo/client";
// ...
export function ClientChild() {
const { data } = useSuspenseQuery(QUERY, { variables: { foo: 1 } });
return <div>...</div>;
}
Why is this a foot gun?
You can see in both of these samples, both need to use the "use client"
directive at the top of the files. As I explained last week, this makes next JS bundle these components into the client bundle. For applications that don’t need to modify their client state to keep in sync with mutations, these components do not need to be in the client bundle. This makes the bundle larger than is necessary and it impacts performance tremendously. And yet, its incredibly natural to use these hooks, after all we have been doing these patterns for years in vanilla react.
What should we do instead?
Instead of relying on traditional react state management, we need to really think of the parts of our applications, and really think about what is static, what needs interactivity, and what needs to be in sync. Lets go back to this wireframe:
Lets pretend that we are implementing this page. The first step to do is identify what can/will change while a user is looking at this page or based on their interactions. These components will be our Client Components. After we identify our Client Components, we keep everything else as Server/RSC components.
One this page, there really only needs to be a handful of elements that should be client components: Search bar with type ahead, the cart button and the add to cart button, and perhaps the product images also change based on the model you select. Lets dig in to a single component, the Shopping Cart.
To implement the shopping cart, the root of the page (or perhaps the layout) would be wrapped in a ShoppingCartProvider
context. The server side code will perform a fetch and get the Cart for the current user, then hydrate the client component using this data. It would look something like:
import { ShoppingCartProvider } from "./ShoppingCart/context";
export default async function Page() {
const shoppingCart = await fetch("api.ecommerce.com/shoppingCart");
return <ShoppingCartProvider shoppingCart={shoppingCart}>...</ShoppingCartProvider>
}
This will get the shopping cart and hydrate it for a user on their page load. This is loads their existing cart as the page is loaded, rather than wait for the cart to be fetched client side before being rendered, resulting in an improved user experience. Note that I do not use "use client"
in this sample. The file ShoppingCart/context.tsx
is responsible for calling "use client"
and only that component will be shipped to the client (at least for now).
However, this page is not done. Now we want to add the current product to the shopping cart when the user presses the Add to Cart
button. As soon as the user presses the button, we want to disable the button, submit a network request to add it the cart, and if successful immediately display it in the cart in the nav, or if the request fails, show some error message. How would something like this work? Like this:
"use client"
import { PropsWithChildren, useState } from "react";
import { useShoppingCart } from "./ShoppingCart/context";
export function ProductModelAndCart(props: PropsWithChildren<{models: {name: string, sku: string, price: number}[]}}>) {
const { isLoading, error, cart, addToCart } = useShoppingCart();
const [selectedModel, setSelectedModel] = useState(models[0]);
return <div>
<div>
{selectedModel.price} - <ModelSelector models={prop.models} onSelect={setSelectedModel} />
</div>
<div>
<h3>Description:</h3>
{props.children}
</div>
<div>
<button onClick={() => addToCart(selectedModel.sku)} disabled={isLoading}>Add to Cart</button>
{error && <ErrorMessage error={error} />}
</div>
</div>
}
A few things to call out here: first this is obviously a client component, it uses "use client"
right at the top. This must be a client component due to all of the state manipulation going on here. Secondly is that this takes children, and places those children right where the Description should be. This is intentional. This allows our description to be pure server components and not rely on any client components to render. This, again, keeps the bundle small and performant.
All told, this page would look something like this:
In this diagram, red components are Server Components, and blue ones are Client Components.
In this diagram, you can clearly see that with server components, data only flows down, but with client components, data can be sent back to the parent node (for example a Context provider), enabling user interactions.
But we can do better.
With the above example, we could optimize this even further, and keep all client side code related to selecting a model as server components. We could do this by having an app layout router like this:
app/
products/
[product-slug]/
[model-slug]
page.tsx
layout.tsx
You would put the ShoppingCartProvider
inside the layout.tsx
, then the the page.tsx
looks something like this:
import { PropsWithChildren} from "react";
export default async function Page(props: {params: {"model-slug": string, "product-slug": string }}) {
const model = await fetchModelForProduct(props.params["product-slug"], props.params["model-slug"]);
return <div>
<div>
{model.price} - <ModelSelector models={model.product.models} product={props.params["product-slug"]}/>
</div>
<div>
<h3>Description:</h3>
{props.children}
</div>
<div>
<AddToCartButton model={model} />
</div>
</div>
}
This works because layout.tsx
is only re-mounts on _hard_ navigations, aka first page loads or refreshes. Adding the ShoppingCarTProvider
here keeps the provider around so long as the user navigates within our app. Next, we are using the URLs and the app router to keep the state of which model we have selected. This keeps our app server side rendered, and if the user is already on our app, NextJS will stream the RSC definition to the client, enabling blazing fast navigations while not needing to wait for a full render of the cart. Now, instead of relying on a client library like Redux/zustand/react-query to manage your state, you are relying on the app router its self.
We tend to overcomplicate state management in applications, and this is usually because state management has historically been a nightmare in frontend applications. NextJS allows us to vastly simplify this state by giving developers the tools to think, By keep all of this in mind, coupled with my post last week, you can develop really great experiences for users. By better balancing client side solutions, you end up with a solution that is more performant, more seo friendly, and simpler to maintain long term.