• Home
  • Blog
  • State Management with Next.js App Router
State Management with Next.js App Router

State Management with Next.js App Router

As a front-end developer, I'm thrilled to delve into the intricacies of state management with Next.js App Router. With the introduction of the new React Server Components architecture, Next.js has taken a significant stride towards streamlining the development of server-rendered applications. However, managing state in this new paradigm presents both opportunities and challenges that developers must navigate effectively.

In the traditional React approach, developers have relied heavily on component state and tools like React Context or Redux to manage global state. However, with Server Components, the concept of component state is no longer applicable, as these components are rendered on the server and cannot maintain mutable state. This paradigm shift requires a fresh perspective on state management, forcing developers to rethink their approach and explore alternative solutions.

One compelling solution for managing state in a Next.js App Router environment is the use of React Hooks in Client Components. These components are rendered on the client-side and can maintain their own state, which can be shared across Server Components through props. By leveraging hooks like useState and useEffect, developers can encapsulate and manage state within Client Components, ensuring a seamless integration with Server Components.

Here's an example of how we can manage state within a Client Component using the useState hook:

"use client";

import { useState } from "react";

const CounterComponent = () => {
  const [count, setCount] = useState(0);

  const incrementCount = () => {
    setCount(count + 1);
  };

  const decrementCount = () => {
    setCount(count - 1);
  };

  return (
    <div>
      <button onClick={decrementCount}>-</button>
      <span>{count}</span>
      <button onClick={incrementCount}>+</button>
    </div>
  );
};

export default CounterComponent;

In this example, we have a Client Component CounterComponent that manages a local state count using the useState hook. The component renders a simple UI with buttons to increment and decrement the counter value. By encapsulating the state management logic within this Client Component, we can seamlessly integrate it with Server Components by passing the state as props.

Additionally, Next.js provides a built-in mechanism for managing global state through the use of next/cache. This utility allows developers to cache and revalidate data on the server, enabling efficient state management and ensuring consistency across Server Components. By leveraging tools like cache.set() and cache.get(), developers can store and retrieve global state, effectively mitigating the need for traditional state management solutions like Redux or MobX.

Here's an example of how we can use next/cache to manage global state:

import { cache } from "next/cache";

const globalState = cache.get("globalState") || {
  theme: "light",
  language: "en",
};

const setGlobalState = (newState) => {
  cache.set("globalState", { ...globalState, ...newState });
};

const GlobalStateProvider = ({ children }) => {
  return (
    <div>
      <button onClick={() => setGlobalState({ theme: "dark" })}>
        Toggle Theme
      </button>
      <button onClick={() => setGlobalState({ language: "fr" })}>
        Toggle Language
      </button>
      {children}
    </div>
  );
};

export default GlobalStateProvider;

In this example, we have a GlobalStateProvider component that manages global state using next/cache. The globalState object is initialized with default values and cached on the server using cache.get(). We define a setGlobalState function that updates the global state by merging the new state with the existing state and caching the updated state using cache.set().

The GlobalStateProvider component renders buttons that allow users to toggle the theme and language, triggering state updates by calling setGlobalState. This component can then be used to wrap other Server Components, providing them access to the global state through props.

However, it's important to note that while next/cache offers a powerful solution for managing global state, it also introduces potential pitfalls and complexities. Developers must exercise caution when dealing with cache invalidation, ensuring that stale data is not served to users and that cache updates are propagated efficiently throughout the application.

To illustrate the practical application of state management in Next.js App Router, let's consider a real-world example: an e-commerce application. In this scenario, we might have a Server Component responsible for rendering the product listing page, which fetches and displays a list of products. By leveraging next/cache, we can cache this product data on the server, ensuring efficient rendering and minimizing the need for repeated data fetches.

import { cache } from "next/cache";

const ProductList = async () => {
  const products = cache.get("products") || await fetchProducts();

  cache.set("products", products);

  return (
    <div>
      {products.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
};

export default ProductList;

In this example, the ProductList component is a Server Component that fetches and renders a list of products. It first checks if the product data is already cached using cache.get(). If the cache is empty, it fetches the data from an external API or database. Once the data is available, it caches the products using cache.set() and renders a list of ProductCard components, passing the product data as props.

Simultaneously, we could have a Client Component responsible for managing the shopping cart state. As users interact with the application, adding or removing items from their cart, the shopping cart state can be encapsulated within this Client Component using hooks like useState. This component can then pass the shopping cart data down as props to relevant Server Components, enabling a seamless integration between the two architectural approaches.

"use client";

import { useState } from "react";

const ShoppingCart = () => {
  const [cart, setCart] = useState([]);

  const addToCart = (product) => {
    setCart([...cart, product]);
  };

  const removeFromCart = (productId) => {
    setCart(cart.filter((item) => item.id !== productId));
  };

  return (
    <div>
      <h2>Shopping Cart</h2>
      {cart.length === 0 ? (
        <p>Your cart is empty</p>
      ) : (
        <ul>
          {cart.map((item) => (
            <li key={item.id}>
              {item.name}{" "}
              <button onClick={() => removeFromCart(item.id)}>Remove</button>
            </li>
          ))}
        </ul>
      )}
      <ProductList addToCart={addToCart} />
    </div>
  );
};

export default ShoppingCart;

In this example, the ShoppingCart component is a Client Component that manages the shopping cart state using the useState hook. It provides functions to add and remove items from the cart and renders the cart contents. Additionally, it includes the ProductList Server Component, passing the addToCart function as a prop, allowing users to add products to the cart directly from the product listing.

Furthermore, Next.js App Router provides built-in support for streaming data fetches through the use of React Suspense. This feature allows developers to render initial content while additional data is being fetched and incrementally rendered, resulting in a smoother and more responsive user experience.

import { Suspense } from "react";

const ProductDetails = async ({ productId }) => {
  const product = await fetchProductDetails(productId);

  return (
    <Suspense fallback={<div>Loading...</div>}>
      <ProductDetailsContent product={product} />
    </Suspense>
  );
};

export default ProductDetails;

In this example, the ProductDetails component is a Server Component that fetches and renders product details based on the productId prop. By wrapping the ProductDetailsContent component with React.Suspense, we can render a fallback UI (in this case, "Loading...") while the data is being fetched. Once the data is available, the ProductDetailsContent component will be rendered with the fetched product details.

While the transition to Server Components and the Next.js App Router presents a learning curve, it also opens up exciting opportunities for optimizing performance, simplifying state management, and streamlining the development process. As a front-end developer, embracing these new paradigms and continuously exploring innovative solutions will be crucial in staying ahead of the curve and delivering exceptional user experiences.

img

José Paulino

Front-end Developer based in Miami.