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 Contexts 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

  1. Over relying on client side state management bloats client side bundles, dragging down performance with it.
  2. 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.
  3. 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:

A diagram showing an example next JS component tree, where blue nodes are client components and red nodes are server components.

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.

Read More Related Posts

Read Latest Posts

Server Side State management in NextJS: a deep dive into React Cache

Posted in NextJS on Oct 18th, 2024

In previous posts, I explored how Server Components in NextJS improve performance by offloading more rendering to the server. Managing server-side state differs from client-side, as it’s static and immutable during a single render. NextJS provides tools like cache, unstable_cache, and patched fetch to manage this state efficiently, reducing prop drilling and simplifying code. These tools offer caching across requests, but require a shift from traditional client-side approaches for better performance

Read More->

NextJS foot guns: Over-reliance on Client Side State

Posted in NextJS on Oct 10th, 2024

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.

Read More->

Architecting NextJS Applications for Better Performance

Posted in JAM Stack on Oct 2nd, 2024

Explore how to optimize React 18 applications using Next.js for better performance and SEO. It covers the use of Server Components to reduce JavaScript on the client, improve Core Web Vitals (CWV), and enhance User Experience (UX). Key strategies include isolating client components, minimizing their size, and balancing client-server rendering. It also highlights Next.js’s render lifecycle and provides tips for building faster, SEO-friendly applications.

Read More->

Authorization in GraphQL

Posted in GraphQL on Jun 8th, 2020

GraphQL is a great new technology. It fills many gaps the REST leaves. However, there are challengs with GraphQL. One of those challenges are authorization and authentication.

Read More->