Introduction to Server-Side Rendering with MobX
State management is a cornerstone of modern React applications, and MobX is one of the most popular choices due to its simplicity and reactive programming model. However, when moving from a purely client-side rendered (CSR) application to a server-rendered architecture, developers face unique challenges. Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR) require careful handling of state to ensure that the server and client remain in sync.
What is SSR, SSG, and ISR?
- Server-Side Rendering (SSR): The page is rendered on the server on every request. It ensures the client always receives the most up-to-date data, which is ideal for highly dynamic, user-specific pages.
- Static Site Generation (SSG): The page is rendered once at build time. The HTML is cached and served to all users. This provides blazing-fast performance and is perfect for blogs, documentation, and marketing pages.
- Incremental Static Regeneration (ISR): A hybrid approach where pages are generated statically but can be updated in the background after a specified time interval (or on-demand). It combines the speed of SSG with the freshness of SSR.
Why Does It Matter for MobX?
In a standard client-side React app, MobX stores are instantiated once and live for the duration of the user's session. In SSR, the server handles multiple requests simultaneously. If you use a global singleton MobX store on the server, you risk cross-request state pollution, where User A's data leaks into User B's request. Furthermore, you must serialize the server's MobX state and pass it to the client so the client can "hydrate" the application without flickering or making duplicate API calls.
Setting Up MobX for Next.js
To demonstrate SSR, SSG, and ISR with MobX, we will use Next.js, the leading React framework for server rendering. First, we need to create a MobX store architecture that safely handles both server and client environments.
Creating the MobX Store
We will create a store that can be initialized with default data or pre-fetched data. This is crucial for hydration.
// lib/store.js
import { makeAutoObservable } from 'mobx';
class UserStore {
users = [];
constructor(initialData = {}) {
makeAutoObservable(this);
if (initialData.users) {
this.users = initialData.users;
}
}
addUser(user) {
this.users.push(user);
}
clearUsers() {
this.users = [];
}
}
class RootStore {
userStore;
constructor(initialData = {}) {
this.userStore = new UserStore(initialData.userStore);
}
}
let clientStore = null;
// This function ensures we only create a singleton on the client,
// but always create a fresh instance on the server.
export function initializeStore(initialData = null) {
const _store = new RootStore(initialData);
// If we are on the server, always return a fresh store
if (typeof window === 'undefined') {
return _store;
}
// If we are on the client and haven't created a store yet, create one
if (!clientStore) {
clientStore = _store;
}
return clientStore;
}
Hydrating State on the Client
Next, we need a custom App component to provide the store to our React component tree using React Context.
// pages/_app.js
import { createContext, useContext } from 'react';
import { initializeStore } from '../lib/store';
const StoreContext = createContext();
let store;
export function useStore() {
const context = useContext(StoreContext);
if (!context) {
throw new Error('useStore must be used within StoreProvider');
}
return context;
}
export function StoreProvider({ children, initialState }) {
store = initializeStore(initialState);
return (
<StoreContext.Provider value={store}>
{children}
</StoreContext.Provider>
);
}
function MyApp({ Component, pageProps }) {
return (
<StoreProvider initialState={pageProps.initialState}>
<Component {...pageProps} />
</StoreProvider>
);
}
export default MyApp;
Implementing SSR, SSG, and ISR
Now that our store is set up to handle hydration safely, we can implement the three rendering strategies. In all cases, the pattern is the same: fetch data on the server, initialize the store with that data, serialize the store to plain JSON, and pass it down as props.
Server-Side Rendering (SSR)
For SSR, we use getServerSideProps. This function runs on every request. We fetch the data, populate the store, and extract the serializable state.
// pages/ssr.js
import { useStore } from '../_app';
import { initializeStore } from '../lib/store';
import { observer } from 'mobx-react-lite';
export async function getServerSideProps() {
// 1. Fetch data from an API or database
const res = await fetch('https://jsonplaceholder.typicode.com/users');
const users = await res.json();
// 2. Initialize a fresh store instance with the fetched data
const mobxStore = initializeStore({
userStore: {
users: users,
},
});
// 3. Serialize the store to pass it as props
return {
props: {
initialState: {
userStore: {
users: mobxStore.userStore.users,
},
},
},
};
}
const SSRPage = observer(() => {
const store = useStore();
return (
<div>
<h1>SSR with MobX</h1>
<ul>
{store.userStore.users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
});
export default SSRPage;
Static Site Generation (SSG)
For SSG, we use getStaticProps. This runs once at build time. The code is almost identical to SSR, but the execution context and caching behavior are different.
// pages/ssg.js
import { useStore } from '../_app';
import { initializeStore } from '../lib/store';
import { observer } from 'mobx-react-lite';
export async function getStaticProps() {
const res = await fetch('https://jsonplaceholder.typicode.com/users');
const users = await res.json();
const mobxStore = initializeStore({
userStore: {
users: users,
},
});
return {
props: {
initialState: {
userStore: {
users: mobxStore.userStore.users,
},
},
},
};
}
const SSGPage = observer(() => {
const store = useStore();
return (
<div>
<h1>SSG with MobX</h1>
<ul>
{store.userStore.users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
});
export default SSGPage;
Incremental Static Regeneration (ISR)
ISR builds upon SSG by adding a revalidate property to the returned props object. This tells Next.js to regenerate the page in the background if a request comes in after the specified number of seconds.
// pages/isr.js
import { useStore } from '../_app';
import { initializeStore } from '../lib/store';
import { observer } from 'mobx-react-lite';
export async function getStaticProps() {
const res = await fetch('https://jsonplaceholder.typicode.com/users');
const users = await res.json();
const mobxStore = initializeStore({
userStore: {
users: users,
},
});
return {
props: {
initialState: {
userStore: {
users: mobxStore.userStore.users,
},
},
// Re-generate the page at most once every 60 seconds
revalidate: 60,
},
};
}
const ISRPage = observer(() => {
const store = useStore();
return (
<div>
<h1>ISR with MobX</h1>
<p>This page is statically generated but updates every 60 seconds.</p>
<ul>
{store.userStore.users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
});
export default ISRPage;
Best Practices for MobX and SSR
- Avoid Global Singletons on the Server: Always check
typeof window === 'undefined'. If true, return a new store instance for every request. If you use a singleton on the server, concurrent requests will share state, leading to massive security and data integrity issues. - Serialize Carefully: MobX stores contain observables and computeds, which are not plain JSON. When passing state from
getServerSidePropsorgetStaticPropsto the client, extract the raw data (e.g.,mobxStore.userStore.users) rather than passing the store object itself. - Use
observerCorrectly: Ensure your page components are wrapped in MobX'sobserverHOC so they react to state changes on the client after hydration. - Handle Client-Side Navigation: When navigating between pages on the client side,
getServerSidePropsruns on the client. Ensure your store initialization logic gracefully merges new data without destroying unrelated client-side state.
Conclusion
Integrating MobX with Server-Side Rendering, Static Site Generation, and Incremental Static Regeneration unlocks powerful capabilities for building fast, SEO-friendly React applications. By understanding the lifecycle of server rendering and strictly adhering to the pattern of fetching data, initializing a request-isolated store, and serializing the state for client hydration, you can leverage MobX's reactive state management without sacrificing the performance benefits of modern rendering strategies. Always remember to keep your server state isolated per request and serialize your data carefully to maintain a seamless experience between the server and the browser.