Thursday, September 12, 2024
Responsibly managing context with Next JS and the app router
Compared to SPA React apps, Next.JS unlocks the power to improve performance with context - with a little more added responsibility.
Sean Fong
@seancfong
The importance of context
When web apps become increasingly complex, managing and mutating state across multiple components in the tree also scales in complexity. This manifests into the issue of prop drilling - having components forward props to children components, without necessarily using them at all.
This job calls for the use of React’s createContext , defining a provider where any child component can access its values.
How we understand context in Single Page Applications
In SPAs (e.g. Vite or CRA) that initialize React on the client, fetching data from a server and populating the context is fairly straightforward - just pass it into the provider via props.
App.tsx
lang:tsx
export const App = () => {const [fetchedData, setFetchedData] = useState<TodoData>([]);useEffect(() => {const fetchData = async () => {const response = await fetch(...)const data = await response.json()setFetchedData(data);}fetchData();}, [...])return (<TodoProvider initialData={fetchedData}><TodoList /></TodoProvider>);};
Using context with Next.JS
In the Next.JS app router, we have the control over managing server components and client components. We can use these to our advantage to improve the performance bottlenecks from the SPA approach:
1. Initiate the context on the server
The fetch can be done on the server with a server component, and the value can be forwarded to the context on the client. This way, we don’t need to wait for the client to download the JS bundle, build the VDOM, fetch the data, (and so on) as another round-trip back to the server.
page.tsx
lang:tsx
export default async function Page() {// Assuming validateUser() reads cookies, this route will// automatically opt into dynamic rendering.const user = await validateUser();// getTodoData() can be a direct database call,// as it is fetched on the server.const todoData: TodoData = await getTodoData(user);return (<div><h1>Todo List</h1><TodoProvider initialData={todoData}><TodoList /></TodoProvider></div>);};
Warning: Third-party libraries that implement a global store (e.g. Zustand) are designed for SPAs that strictly maintain client state. Never populate a global store (or maintain any state) in a server component unless you use React context, as global store values will be shared across requests between different users.
This is a good place to start. However, the rendering of the entire page is blocked because of awaiting the fetch in the server component. Ideally, we would want to render the static content (the “Todo List” header) immediately while waiting for the context data.
2. RSC streaming and the React use API
Next.JS server components implement React Server Components in the app router, which allow us to split the dynamic server render into different chunks.
So instead of awaiting the getTodoData at the page level, we can pass the promise into the context and consume it using React’s canary use API (available in Next.JS 14).
While the promise is pending on the server, the context provider and its children will suspend rendering until it resolves. Until then, we can wrap the component in a Suspense boundary and display a fallback TodoLoadingSkeleton UI.
page.tsx
lang:tsx
export default async function Page() {const user = await validateUser();const todoPromise: Promise<TodoData> = getTodoData(user);return (<div><h1>Todo List</h1><Suspense fallback={<TodoLoadingSkeleton />}><TodoProvider todoPromise={todoPromise}><TodoList /></TodoProvider></Suspense></div>);}
Note: In the RSC tree, since TodoList is passed as the children prop in TodoProvider, it is also treated as a server component by default even though TodoProvider is a client component. This is a fairly useful exception to the server/client composition pattern if we want to handle anything else on the server in TodoList after the component “un-suspends”.
Now we are able to efficiently fetch and render the todo list. But the data is out of sync with the server, so we need a way of letting the server know we intend to mutate a todo item and display the updated data on the client.
3. Mutations with server actions
Instead of directly sending a POST request to the server and writing a route handler, Next.JS introduced form actions. These take the form of an exported async function with the ‘use server’ decorator, which can be invoked on the client but executes securely on the server.
To keep a clean separation of UI and logic, it’s much better practice to define it in a separate file. If you’re brave enough, you can inline the server action within a client component.
For simplicity, let’s account for adding and deleting todos. To test failed inserts, let’s suppose an insert fails when a todo is blank.
app/actions/todos.ts
lang:ts
"use server";export async function addTodoAction(id: string,title: string,createdAt: string) {if (!title) {throw new Error("Todo cannot be empty");}await db.insert("todos", { id, title, createdAt });revalidatePath("/", "page");}export async function removeTodoAction(id: string) {await db.remove("todos", { id });revalidatePath("/", "page");}
Ensure that your server actions call revalidatePath after the mutations to ensure that the revalidated data isn’t stale. More on this in the next section.
4. Optimistic Updates
Here’s where the real magic happens. React’s new useOptimistic hook synchronizes client with the server state. Imagine it as a useState with an automatic undo functionality, just in case something wrong happens on the server.
“Optimistic” refers to assuming that the server will succeed in the request, so we might as well show the immediate outcome to the user instead of waiting for the server to finish first. This results in improved UX, as the page appears much faster than it might actually be. However, it is not recommended for requests that may result in race conditions or leave an unpredictable state on the client.
useOptimistic takes two arguments:
- the current client-side state
- an update function that also takes two arguments: currentState and optimisticValue , returning the updated state
Or simply put in TypeScript: <T, U>(state: T, (previousState: T, optimisticValue: U) => T)
Because we want to either add or remove a todo, we can make our update function a reducer, and the optimistic value can be an object that has the new todo data along with the type of action we want to perform.
app/components/todo-reducer.tsx
lang:tsx
type AddTodoAction = {type: "ADD_TODO";payload: {id: string;title: string;};};type RemoveTodoAction = {type: "REMOVE_TODO";payload: {id: string;};};type TodoAction = AddTodoAction | RemoveTodoAction;export function todoReducer(state: TodoData | undefined,optimisticAction: TodoAction): TodoData {const currentTodosState = state ?? [];switch (optimisticValue.type) {case "ADD_TODO": {const { id, title } = optimisticAction.payload;const newTodo = {id,title,};return [...currentTodosState, newTodo];}case "REMOVE_TODO": {const { id } = optimisticAction.payload;return currentTodosState.filter((todo) => todo.id !== id);}default:return currentTodosState;}}
We’ll also update the context with useOptimistic instead of useState .
Let’s also define two functions: addTodo and removeTodo that first mutate the client state to optimistically show the new value, and then awaits their respective server actions we defined previously.
app/components/todo-context.tsx
lang:ts
interface TodoListContextType {todos: TodoData;addTodo: (title: string) => void;removeTodo: (id: string) => void;}const TodoContext = createContext<TodoListContextType | undefined>(undefined);export const TodoProvider: React.FC<{children: ReactNode;todoPromise: Promise<TodoData>;}> = ({ children, todoPromise }) => {const initialTodos = use(todoPromise);const [optimisticTodos, updateOptmisticTodos] = useOptimistic(initialTodos,todoReducer);const addTodo = useCallback(async (title: string) => {// Generate an id and track createdAt on the client to// ensure that client and server state are the same.// Ensure to validate these on the database layer, as anything// initiated on the client may not be legitimate.const id = generateId();updateOptmisticTodos({ type: "ADD_TODO", payload: { id, title } });const createdAt = new Date().toISOString();await addTodoAction(id, title, createdAt);},[updateOptmisticTodos]);const removeTodo = useCallback(async (id: string) => {updateOptmisticTodos({ type: "REMOVE_TODO", payload: { id } });await removeTodoAction(id);},[updateOptmisticTodos]);const value = useMemo(() => ({ todos: optimisticTodos, addTodo, removeTodo }),[optimisticTodos, addTodo, removeTodo]);return <TodoContext.Provider value={value}>{children}</TodoContext.Provider>;};export const useTodoContext = ...
Putting it all together
There’s definitely plenty of moving parts when maintaining a performant context manager in Next.JS. Although this todo example may be slightly excessive, it demonstrates the true potential of RSC’s and streaming to make your web app much more delightful to use.
page.tsx
lang:tsx
export default async function Page() {const user = await validateUser();const todoPromise: Promise<TodoData> = getTodoData(user);return (<div><h1>Todo List</h1><Suspense fallback={<TodoLoadingSkeleton />}><TodoProvider todoPromise={todoPromise}><TodoList /></TodoProvider></Suspense></div>);}function TodoLoadingSkeleton() {return <>Loading Todos...</>;}
Here's a link to the codesandbox. Feel free to play around with adding and removing todo's, even with artificial delays between requests.