https://github.com/shakacode/use-ssr-computation.macro
A Babel macro designed to offload computations to the server-side, with the results attached to the HTML as JSON.
On the client-side, the macro mimics the behavior of React.useMemo, fetching results cached during Server-Side Rendering (SSR). This reduces client-side bundle size by eliminating unnecessary code and library imports.
It's similar to React Server Components (RSC) but requires less refactoring.
In the modern web ecosystem, performance is king. With use-ssr-computation.macro, the objective is simple: perform computation-heavy tasks server-side and send only the essential results to the client. This ensures a more responsive user experience, especially on lower-end devices.
The useSSRComputation macro is used to execute code on the server-side and cache the result for the client-side.
Arguments:
path- the path to the file that contains the code to be executed on the server-side.dependencies- an array of dependencies that will be passed as arguments to the function in thepathfile.options- SSR computation options object. It can contain the following options:webpackChunkName- the name of the webpack chunk that will be created for the SSR computation file. It's useful for code splitting. If not provided, the default chunk name will bedefault-ssr-computations.skip- a boolean value that indicates whether the SSR computation should be skipped or not. It's useful for development purposes. If not provided, the default value will befalse. It's necessary because React hooks can't be called conditionally. Instead, we can use theskipoption to skip the SSR computation until needed.
Return Value:
- The return value of the
useSSRComputationhook is the result of the server-side computation. It will benullif the computation hasn't been executed yet (skipped or still downloading the SSR computation file).
Simply put, execute computations on the server, cache the result, and make it available on the client-side.
The computation file must export a function named compute that takes the dependencies as arguments and returns the result of the computation.
The compute function must be a sync function and it must returns a result that can be serialized. It can't return a function or a class.
The compute function can return NoResult if the computation is not ready yet. In this case, the useSSRComputation hook will return the last result cached or it will return null if no result is cached before.
Dependencies Each dependency should be of the dependency type:
type Dependency = string | number | { uniqueId: string };You can pass either the primitive types string or number or any other object that has a uniqueId property of type string.
This is necessary to serialize the dependencies and pass them to the client-side. The uniqueId is used to serialize the dependency and to compare it with the client-side dependency.
import { NoResult } from "use-ssr-computation.runtime";
// someLogic.ssr-computation.js
export const compute = <TResult>(...dependencies: Dependency[]): TResult | typeof NoResult => {
// Your server-side computation logic here
};After defining your logic:
import { useSSRComputation } from "use-ssr-computation.macro";
const computationResult = useSSRComputation("./path-to-someLogic.ssr-computation");Once the useSSRComputation hook is invoked, here’s what happens:
- Server-Side: The provided computation logic is executed.
- Cache: The result is stored in an ssr-computation cache.
- Client-Side: The cached result is used, without needing to re-run or include the original computation logic in the client bundle. It will only be re-run if the dependencies change. In this case, the
ssr-computationfile will be imported dynamically and executed.
Smaller Client Bundles: Only the results are sent to the client, not the actual logic or heavy libraries. Faster Initial Loads: Reduced JavaScript means faster parsing and execution times.
Use the luxon package to dynamically format a user's birthdate on the server side, ensuring the client-side bundle remains lightweight by loading the library only when the birthdate changes.
1- Server-side computation file named ./formatBirthDate.ssr-computation.js:
// ./formatBirthdate.ssr-computation.js
import { DateTime } from "luxon";
export const compute = (birthdate) => {
return DateTime.fromISO(birthdate).toLocaleString(DateTime.DATE_FULL);
};// App.js
import React, { useState } from "react";
import { useSSRComputation } from "use-ssr-computation.macro";
const App = () => {
const [birthDate, setBirthDate] = useState("1990-07-20"); // Example date
const formattedDate = useSSRComputation("./formatBirthDate.ssr-computation", [birthDate]);
return (
<div>
User's birthdate is: {formattedDate}
<input
type="date"
value={birthDate}
onChange={e => setBirthDate(e.target.value)}
/>
</div>
);
};
export default App;The luxon package is included on server-budle and executed on the server side. The formatted date is passed to the client side.
If the user changes their birthdate on the client side, the SSR computation file will be dynamically loaded on the client side, execute it, and return the updated result.
The macro now supports a "subscription" mechanism. This allows dynamic computations that can update over time.
Your computation file should have the usual compute function and an additional subscribe function if it supports dynamic updates:
The subscription function will only be called on the client side. Only in the following cases:
- The computation is not cached before (there is a cache miss).
- The
fetchSubscriptionsfunction is called. In this case, all SSR computation files are downloaded and executed.
The compute function will be called first and then the subscribe function.
export const compute = () => {
// computation logic
};
export const subscribe = (getCurrentResult, next, ...dependencies) => {
// subscription logic
return {
unsubscribe: () => {
// cleanup logic
},
};
};getCurrentResultfunction returns the last result returned by the computation. It will returnnullif the computation hasn't been executed yet (not cached before and thecomputefunction returnedNoResult).nextfunction is used to update the result. It takes one argument which is the new result.
After a computation is initially fetched, you can subscribe to updates using the fetchSubscriptions function:
import { useSSRComputation, fetchSubscriptions } from "use-ssr-computation.macro";
const result = useSSRComputation("./path-to-computeData.ssr-computation");When you want to start listening for changes, simply invoke fetchSubscriptions():
fetchSubscriptions();For more details about the subscriptions feature, please check the subscriptions example with React On Rails Pro. Also, you can look at the Add support for Subscriptions PR
A practical application of the macro with the new subscriptions feature is formatting and updating the current time every minute using the luxon library.
1- Server-side Computation with Subscription:
// formattedTime.ssr-computation.js
import { DateTime } from "luxon";
export const compute = () => {
return DateTime.now().toLocaleString(DateTime.TIME_SIMPLE);
};
let timerId;
export const subscribe = (getCurrentResult, next) => {
if (timerId) clearInterval(timerId);
timerId = setInterval(() => {
const newValue = compute();
if (newValue !== getCurrentResult()) {
next(newValue);
}
}, 60000); // Update every minute
return {
unsubscribe: () => {
clearInterval(timerId);
},
};
};2-Application Integration:
// App.js
import React, { useEffect } from "react";
import { useSSRComputation } from "use-ssr-computation.macro";
import { fetchSubscriptions } from "use-ssr-computation.runtime";
const App = () => {
const formattedTime = useSSRComputation("./formattedTime.ssr-computation");
useEffect(() => {
const timeoutId = setTimeout(() => {
fetchSubscriptions();
}, 60000); // Subscribe after 1 minute
return () => clearTimeout(timeoutId);
}, []);
return (
<div>
Current time is: {formattedTime}
</div>
);
};
export default App;The computation module and the luxon package are not loaded immediately, thereby optimizing initial page load times. Only after 1 minute (when the fetchSubscriptions function is called) will the library and module be dynamically loaded and the subscription set up to update the time.
With this mechanism, we've effectively lazy-loaded our time formatting operation, deferring the load of luxon and ensuring minimal performance impact during the critical initial page render.
Why go through this trouble?
- Lazy Loading: Import heavy libraries only when they're truly necessary.
- Reduced Network Payload: Only essential data is sent over the wire.
- Optimized CPU Utilization: Client devices do less computational work, leading to better responsiveness.
NPM
npm install babel-plugin-macros @shakacode/use-ssr-computation.macro @shakacode/use-ssr-computation.runtimeYarn
yarn add babel-plugin-macros @shakacode/use-ssr-computation.macro @shakacode/use-ssr-computation.runtime1- Add the following to your .babelrc that's responsible for compiling your server-bundle file:
{
"plugins": [
"macros",
{
"useSSRComputation": {
"side": "server"
}
}
]
}2- Add the following to your .babelrc that's responsible for compiling your client-bundle file:
{
"plugins": [
"macros",
{
"useSSRComputation": {
"side": "client"
}
}
]
}Add the following to your returned HTML from the server after rendering your React app:
import { getSSRCache } from "use-ssr-computation.runtime";<script
dangerouslySetInnerHTML={{
__html: `
window.__SSR_COMPUTATION_CACHE=${JSON.stringify(getSSRCache())};
`,
}}
/>If you are using Typescript, don't forget to add __SSR_COMPUTATION_CACHE to the Window interface:
import { SSRCache } from "use-ssr-computation.runtime";
interface Window {
__SSR_COMPUTATION_CACHE: SSRCache;
}Add the following to your client-side entry file before rendering your React app:
import { setSSRCache } from "use-ssr-computation.runtime";
const cache = window.__SSR_COMPUTATION_CACHE;
if (cache) {
setSSRCache(cache);
}The macro works by transforming the useSSRComputation hook into a function call that's executed on the server-side and cached for the client-side. The macro also transforms the useSSRComputation hook into a function call that's executed on the client-side, mimicking the behavior of React.useMemo.
import { useSSRComputation } from "use-ssr-computation.macro";
const x = useSSRComputation("./a.ssr-computation", [1, 2, 3], {});↓ ↓ ↓ ↓ ↓ ↓
Server Bundle
import * as __a from "./a.ssr-computation";
import useSSRComputation_Server from "@shakacode/use-ssr-computation.runtime/lib/useSSRComputation_Server";
const x = useSSRComputation_Server(__a, [1, 2, 3], {}, "app/a.ssr-computation");Client Bundle
function _dynamicImport_() {
return import(
/* webpackChunkName: "default-ssr-computations" */ "./a.ssr-computation"
);
}
import useSSRComputation_Client from "@shakacode/use-ssr-computation.runtime/lib/useSSRComputation_Client";
const x = useSSRComputation_Client(
_dynamicImport_,
[1, 2, 3],
{},
"app/a.ssr-computation",
);For more examples of the code transformation, please check the snapshot tests