Open polubis opened 1 year ago
A few differences which I could observe when rewriting existed article to new syntax:
There is content:
We'll dive through the concept of state management in NextJS applications which are using Zustand as state manager. We'll create a small hook that will sync the state with the server and the client.
The NextJS is a real game changer in the web ecosystem. However, integration with third-party libraries for state management like Zustand or Redux may be challenging. That's why today, we'll integrate Next.js app with Zustand, and at the end, you will know how to manage your state, keep it synced with the server and manage effectively.
Check the following code snippet which shows the difference in state returned from the server and the initial client state.
// @@@ Inside use-counter-store.ts @@@.
export interface CounterStore {
counter: number;
}
export const useCounterStore = create<CounterStore>(() => ({
counter: 0,
}));
// @@@ Inside counter page @@@.
interface CounterPageProps {
counter: number;
}
export const getStaticProps: GetStaticProps<CounterPageProps> = async () => {
return {
props: {
counter: 3,
},
};
};
const CounterPage = ({ counter }: CounterPageProps) => {
// Counter returned from server on first render.
console.log(counter); // 3.
const counterStore = useCounterStore();
// Client "counterStore" value on first render.
console.log(counterStore.counter); // 0.
return <div>Any JSX content</div>;
};
export default CounterPage;
The problem with counter value difference
In console.log statements we have different values for counter variables from the server and client. This is a big problem, our application will be buggy as hell... So, we need to:
read the state from the server on the client before hydration,
replace the Zustand store state - make synchronization between server/client.
When you try to run this code, you'll get the following error from NextJS in a development environment.
Hydration error
Hydration refers to the process of taking a server-rendered React application and making it interactive on the client-side. NextJS combines server-side rendering (SSR) with client-side rendering (CSR) to improve the performance and user experience of web applications.
We need to sync the state before the hydration. Our useCounterStore hook created by create function from Zustand, exposes static method to change the state - setState.
// Example of state change via the static method.
useCounterStore.setState({ counter: 10 });
The state change done in Zustand happens immediately. It's not async operation - you may understand it as a simple variable change.
In addition, the components or other application layers are listening for state changes. It's a typical implementation of observable pattern. If you're reading state with useCounterStore hook - you're automatically subscribed and the component will rerender after the state change.
const CounterComponent = () => {
// When state change - component rerenders.
const counterStore = useCounterStore();
return <div>{counterStore.counter}</div>;
};
With that information, we may implement the following hook:
// The "isClient" is just check for window !== undefined.
import { isClient } from '@system/utils';
import { useMemo, useRef } from 'react';
import { create, type StoreApi, type UseBoundStore } from 'zustand';
const useStoreSync = <T>(
useStore: UseBoundStore<StoreApi<T>>,
state: T
): UseBoundStore<StoreApi<T>> => {
// Ref to store flag and avoid rerender.
const unsynced = useRef(true);
// Creating store hook with initial state from the server.
const useServerStore = useMemo(() => create<T>(() => state), []);
if (unsynced.current) {
// Setting state and changing flag.
useStore.setState(state);
unsynced.current = false;
}
// For "client" we'll return the original store.
// For "server" we'll return the newly created one.
return isClient() ? useStore : useServerStore;
};
export { useStoreSync };
Implementation of useStoreSync hook
And then we'll use our hook as follows:
const CounterPage = ({ counter }: CounterPageProps) => {
console.log(counter); // 3.
const counterStore = useStoreSync(useCounterStore, { counter })();
console.log(counterStore.counter); // 3.
return <div>{counterStore.counter}</div>; // It's 3.
};
Okay, so let's describe what happened. First, we are passing to the useStoreSync hook, the useCounterStore original hook and the state from server.
// Original client hook and state to set before hydration.
const counterStore = useStoreSync(useCounterStore, { counter });
Next, we're checking about synchronization status. If a sync has not happened yet, we're setting the initial state as the state passed from the server, and then we're setting the flag to false. Thanks to this we'll avoid multiple state changes and not needed rerenders.
// Ref to store flag and avoid rerender.
const unsynced = useRef(true);
// Creating default store with initial state.
const useServerStore = useMemo(() => create<T>(() => state), []);
if (unsynced.current) {
// Setting state and changing flag.
useStore.setState(state);
unsynced.current = false;
}
The most important part is to call it at the first line of initial page component and we need to do it only once per store.
const CounterPage = ({ counter }: CounterPageProps) => {
console.log(counter) // 3.
const counterStore = useStoreSync(useCounterStore, { counter })()
console.log(counterStore.counter) // 3.
return <div>{counterStore.counter}</div> // It's 3.
}
To show the result of integration we need some real-world examples instead of the counter one. Let's say we have two different views in which we want to display articles. The first one should fetch them only on the client, the second one should retrieve them on the server and sync up with our client state.
This should look like this:
Small app demo
Take a look at the view behavior when changing url. On the first tab when we change URLs we have Loading message.
In second tab, we display articles immediately (they are taken from the server) and synced up with the client as I mentioned before.
Let's say we're using getStaticProps or getServerProps and we want to load the list of articles on the server. Then, we want to pass the list from the server and generate a page. The data loaded on the server must be synced with the client state. To achieve that we need the following code:
interface ArticlesPageProps {
state: Articles.Ok
}
export const getStaticProps: GetStaticProps<ArticlesPageProps> = async () => {
return {
props: {
state: {
is: "ok",
articles: await getArticles("en", "Accepted"),
},
},
}
}
const ArticlesPage = ({ state }: ArticlesPageProps) => {
useStoreSync(useArticlesStore, state)()
// Prints state that is synced now!
console.log(state)
return <AnyOtherComponentWithStoreUsedIsSafeNow />
}
The same rule applies for getServerProps, just the function name will be different, so let's skip that.
How to use it with server components in new NextJS API? We need a special, client-side only component that will use our hook and return null. Take a look at the following code and notice the usage of use client directive.
"use client"; // It's required!
import { useArticlesStore } from "../store/articles";
import { useStoreSync } from "../libs/use-store-sync";
import type { SyncedWithArticlesProps } from "./synced-with-articles.defs";
export interface SyncedWithArticlesProps {
state: ArticlesStore.State;
}
export const SyncedWithArticles = ({ state }: SyncedWithArticlesProps) => {
useStoreSync(useArticlesStore, state);
return null;
};
Now, let's use our component inside ArticlesPage:
import { ArticlesSyncedWithClientView } from "../../views/articles-server-synced-with-client.view";
import { SyncedWithArticles } from "../../client/synced-with-articles";
import { getArticles } from "../../services/articles";
import { articles_mappers, articles_states } from "../../store/articles";
import { headers } from "next/headers";
export default async function ArticlesServerSyncedWithClientPage() {
const headersList = headers();
// For NextJS server actions executed on server side
// we need full server URL. Alias like "/api/articles" works
// only on client!
const articles = await getArticles(
`https://${headersList.get("host")}` ?? ""
);
const state = articles_states.ok(articles_mappers.toModel(articles));
return (
<>
<SyncedWithArticles state={state} />
<h1 className="my-4 p-3 text-3xl font-bold underline bg-green-100">
Articles server synced with client
</h1>
{/* Here we're safe to use our state! */}
<AnyComponentThatIsUsingZustandState />
</>
);
}
Notice that we used the SyncedWithArticles component at the beginning of the JSX code - it triggers the sync procedure.
Here you have the hook implementation:
import { isClient } from '@system/utils';
import { useMemo, useRef } from 'react';
import { create, type StoreApi, type UseBoundStore } from 'zustand';
const useStoreSync = <T>(
useStore: UseBoundStore<StoreApi<T>>,
state: T
): UseBoundStore<StoreApi<T>> => {
const unsynced = useRef(true);
const useServerStore = useMemo(() => create<T>(() => state), []);
if (unsynced.current) {
useStore.setState(state);
unsynced.current = false;
}
return isClient() ? useStore : useServerStore;
};
export { useStoreSync };
Under the following Codesandbox, you have the implementation discussed in this article.
If you are curious how you may use this hook and would like to have more examples, just check the following Dream stack for React developer repository.
We integrated and synced up the state from the server with the client one. A small hook needs to be called at the beginning. Life is now much easier!
What is really cool, the same solution will work for other frameworks like Gatsby.
The most important part is that the implementation differs for "old" and "new" NextJS. With the previous getStaticProps and getServerProps we need just to call a hook at the beginning of the page.
However, for server components and server actions we need a special wrapper component that will be used only on client.
If you want to now more about Zustand and NextJS, feel free to check these articles:
⭐ Working with selectors in Zustand and Redux
🥇 Comparing Redux with Zustand for state management in React
Context
Write here as comments all problems that you'll find during using new aditor under /en/articles-creator path.
If something is bad in perspective of UX - just add suggestions or comments.
Definition of done