State Management in Next.js IV.
Hey everybody,
Let’s continue our journey in the world of state management - it is the fourth destination on the path - with the most popular React framework, Next.js. If you’re interested in fresh news and using them this is for you! It’s based on the previous parts and extends them, so it is recommended to read them in advance (first part, second part, third part. 2023 already brought lots of interesting things into Next.js’ and therefore into our life. We're just trying to keep up with the swirling chain of events. As I'm writing these lines, the latest stable version of Next.js is 14. With the use of the app directory - which brings the wonders of React 18 to life - we need to tweak and change our code related to state management. (We already wrote about the app router at the time of its release here.)
Built-in Solutions
Let’s take a look at the native React state management solutions like useState, useReducer, and the React Context API.
About the usage of the Pages router
In case your application uses the pages router then our previous recommendations (first part, second part, third part are still standing.
Before we dive in - as it strongly influences our topic - let's quickly talk about server and client components.
About the Newly Introduced Server Components and Benefits By default, every React component starts its life as a server component. Here are the benefits of this approach:
- Data Fetching: Since this process happens on the server-side, at the data source (via async functional components), it will be faster compared to other data fetching methods.
- Security: Sensitive data like API keys and tokens are stored on the server-side, preventing them from reaching the client side.
- Caching: Results can be cached on the server and reused for subsequent accelerated requests. This happens automatically through and by Next.js.
- Package Size: JS modules are imported and used on the server, eliminating the need for the client to download, parse, and execute JavaScript. This provides significant advantages in SEO optimization as usable content appears earlier for users. Naturally, it greatly impacts the initial page load, the FCP (First Contentful Paint).
- Streaming: Server components allow you to break down rendering work into parts and stream them to the client as they are ready. This enables users to see certain parts of the page earlier without waiting for the entire server-side rendering of the page.
This is all very nice, even amazing, but what’s the catch, one might ask. The limitation lies in the fact that with server components, there's no possibility for user interactions and the use of hooks.
Here come the server components into picture.
Altered Client Components and Their Benefits
The component where we expect interactivity should now include the 'use client' directive at the top of the file, just above the imports. This step establishes a special boundary. Within the file of a client component, other imported modules and components become client components, allowing our previous components to function as usual, preceding the “app router era”.
As we can see on the left side of the image, these components are server components and an error message is displayed. In contrast, on the right side of the image, by utilizing the 'use client' directive, we've created the magical boundary necessary for using hooks, enabling interactivity without encountering any errors.
The Next.js team wants to emphasize that client components are neither better nor worse than server components; they simply serve different purposes. It's important to remember that prior to the “app router era”, every single component was a client component.
Put simply, if you want to use a browser API or hook, the rule of thumb is to include 'use client' at the top of your component.
- Interactivity: Client components are capable of managing state changes, effects, event listening—ultimately establishing connections between the user and the UI.
- Browser API: In client components, we have access to browser APIs such as geolocation and localStorage.
Example of a Client Component and useState in Action
Here is a simple example of a Server component which has a Client component child.
page.tsx
import Counter from "@/app/components/Counter"
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-8">
<h1 className="text-3xl font-bold">My really precious Counter has arrived!</h1>
<Counter/>
</main>
)
}
Counter.tsx
'use client'
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>You clicked {count} times</p>
<button onClick={(prev) => setCount(prev + 1)}>Click me</button>
</div>
)
}
Providers in Use
What is the situation with providers?
The question arises swiftly. Providers form essential parts of state management, whether they are related to the Context API or to a Redux Toolkit Provider, acknowledging that their use is not possible within server components.
From what's been discussed, it's evident that they can only be correctly used as client components. So, we deoptimized our entire component tree (our entire app with providers) just because we wanted to handle state using providers???!?444
The answer is NO, fortunately.
If a client component receives a prop from a server component, for instance, 'children', then it can still be a server component. This is something we can leverage with our providers.
Let's take a look at an example of how this can be achieved.
Example for Provider:
layout.tsx
import {ReactNode} from "react"
import {Inter} from 'next/font/google'
import './globals.css'
import MainProvider from "@/providers/MainProvider"
const inter = Inter({subsets: ['latin']})
export default function RootLayout({
children,
}: {
children: ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>
<MainProvider>
{children}
</MainProvider>
</body>
</html>
)
}
MainProvider.tsx
'use client'
import {ReactNode} from "react"
import {Provider} from 'react-redux'
import {store} from "@/app/store"
const MainProvider = ({children} : {children: ReactNode}) => {
return (
<Provider store={store}>
{children}
</Provider>
)
}
export default MainProvider
In the MainProvider, we observe the usage of the 'use client' directive (which designates the MainProvider as a client component). Here, it's possible to have multiple providers, and the *MainProvider wraps them, rendering its children prop, which, despite its parent being a client component, essentially remains a server component. Within the new app directory router ecosystem, the content of page.tsx will become the children prop.
page.tsx
import moment from "moment"
import Counter from "@/app/components/Counter"
import Test from "@/app/components/Test"
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-8">
<h1 className="text-3xl font-bold">My really precious Counter has arrived!</h1>
<p>{moment().format('MMMM Do YYYY, h:mm:ss a')}</p>
<Counter/>
<Test/>
</main>
)
}
In the page.tsx file, we can identify it as a server component. If its parent, like layout.tsx, hasn’t been 'deopted' yet, then it remains a server component. Suppose we were to consider using the sizable JS library, 'moment', to format dates (though it's advisable to avoid this due to better alternatives like date-fns or the built-in Intl). By doing so, we wouldn’t burden the client, as it's solely loaded on the server-side. Consequently, only the complete HTML is sent to the browser, sparing it from downloading, parsing, and executing the library.
Counter.tsx
'use client'
import {useDispatch, useSelector} from "react-redux"
import {RootState} from "@/app/store"
import {increment, decrement,} from "@/slices/counterSlice"
export default function Counter() {
const count = useSelector((state: RootState) => state.counter.value)
const dispatch = useDispatch()
return (
<>
<p className="text-center">You clicked {count} times</p>
<div className="flex p-2 bg-blue-200 w-96 justify-between">
<button className="p-2 bg-amber-100" onClick={() => dispatch(decrement())}>Decrement</button>
<button className="p-2 bg-amber-100" onClick={() => dispatch(increment())}>Increment</button>
</div>
</>
)
}
At the top of the Counter.tsx file, we observe the 'use client' directive, allowing us to comfortably enjoy the benefits of Redux-provided state management by happily clicking the buttons
A few words and thoughts on the App Router, its stability and of Next.js version 14
In the article, we extensively discussed utilizing the app router for state management, which was introduced in Next.js 13 and officially declared stable only later, specifically in Next.js 13.4.
Surprisingly, two months after this announcement, an official blog post was released, highlighting their further improvements in performance, STABILITY, and developer experience.
As I mentioned at the beginning of the article, the latest version of Next.js is a 14, where perhaps the most significant change was the stabilization of the server actions, promised in version 13. To add a slightly sour note (as it was completely unnecessary as of yet), the transition from 13 to 14 was smooth. Had it been otherwise, I would have been quite disappointed in updating a framework's major version, commonly accompanied by breaking changes, for so few new features. As we've come to expect, Turbopack introduced in Next.js 13 is still not production-ready.
Let’s Summarize What We Have Just Read
About the challenges associated with using the app router introduced with Next.js 13. How to utilize React's built-in hooks for state management, as well as one of the most well-known third-party state management libraries, such as Redux. We've seen the usage of layout and page files, and we've touched upon the use of server and client components.
I hope that after reading the article, you'll find handling state with the Next.js framework easier. Perhaps, you might even come to like it, if all goes well.