Isomorphic Data Cache (SWR)

Technical guide on how to implement background data refreshing and automatic merging in your JopiJS pages.

Overview

While the Static HTML Cache serves the page skeleton, the Data Cache ensures your information remains fresh without blocking the initial render. JopiJS implements a high-performance Stale-While-Revalidate (SWR) pattern that is managed through a single file: pageData.ts.


1. The Data Lifecycle

JopiJS coordinates three distinct phases to manage your data:

  1. Supply (getDataForCache): Executed on the server during the build or when a page is first requested. Its output is embedded directly into the HTML.
  2. Display (usePageData): A React hook that immediately returns the embedded (potentially stale) data and triggers a background update.
  3. Refresh (getRefreshedData): A background call triggered by the browser. It allows the server to return only the "delta" (changes), which JopiJS then merges into the UI.

2. Implementing pageData.ts

This file must reside in the same folder as your page.tsx. It acts as the "Data Provider" for your route.

Phase A: Initial Supply

The getDataForCache function prepares the page's first state.

src/mod_catalog/@routes/products/pageData.ts
import { JopiRequest } from "jopijs";
import type {JopiPageDataProvider} from "jopijs";

export default {
    async getDataForCache({req}) {
        // 1. Determine what the page should display (e.g., fetch all active IDs)
        const productIds = getAllProductToShow();
        
        // 2. We often delegate to the refresh function to avoid code duplication
        return this.getRefreshedData({
            ...params,
            seed: { allProductId: productIds }
        });
    }
} as JopiPageDataProvider;

Phase B: Background Refresh & Merging

The getRefreshedData function is the performance engine. It can differentiate between a full server render and a lightweight browser update.

src/mod_catalog/@routes/products/pageData.ts
export default {
    async getDataForCache({req}) { /* ... */ },

    async getRefreshedData({ seed, isFromBrowser, req }) {
        // PERFORMANCE TIP: If called from the browser, only fetch highly 
        // volatile fields (price, stock) to avoid heavy DB joins.
        const onlyDynamicFields = isFromBrowser;

        const products = await db.fetchProducts(seed.allProductId, onlyDynamicFields);

        if (isFromBrowser) {
            // Do a very short response since the browser
            // will merge with the cached data.
            //
            return {
                items: products
            }
        }

        return {
            // Name this 'items' to enable automatic list merging
            items: products,

            // CRITICAL: Tells JopiJS which field uniquely identifies an item
            itemKey: "idProduct",

            // Global data (merged into the 'global' property of the hook)
            global: {
                pageTitle: "Hardware Shop"
            },

            // Data passed back to the browser for future refreshes
            seed: seed
        };
    }
} as JopiPageDataProvider;

function getAllProductToShow() {
    return [11, 12, 13];
}

function calcProductInfos(allProductId: number[], isFromBrowser: boolean) {
    if (isFromBrowser) {
        return [
            // Come from the browser: we only return the prices and stocks
            // since we known that the other data has not been modified
            // since the data cache has been built into the HTML page
            //
            {idProduct: 11, price: 1011, stock: 11},
            {idProduct: 12, price: 1012, stock: 12},
            {idProduct: 13, price: 1013, stock: 13}
        ];
    } else {
        // Come from the server: we return the full product information.
        return [
            {idProduct: 11, title: "Product 11", description: "Description 11", price: 11, stock: 11},
            {idProduct: 12, title: "Product 12", description: "Description 12", price: 12, stock: 12},
            {idProduct: 13, title: "Product 13", description: "Description 13", price: 13, stock: 13}
        ]
    }
}

3. Consuming Data in React

The usePageData hook provides a unified state that updates automatically when the background refresh completes.

src/mod_catalog/@routes/products/page.tsx
import { usePageData, setPageTitle } from "jopijs/ui";

export default function ProductPage() {
    const { items, global, isLoading } = usePageData();

    // Side effect: update document title from cached global data
    if (global?.pageTitle) setPageTitle(global.pageTitle);

    return (
        <div>
            <h1>{global?.pageTitle}</h1>
            
            {isLoading && <div className="spinner">Updating prices...</div>}

            <ul>
                {items?.map((product) => (
                    <li key={product.idProduct}>
                        {product.title} - <strong>${product.price}</strong>
                        <div>Stock: {product.stock}</div>
                    </li>
                ))}
            </ul>
        </div>
    );
}

4. Security: Seed Integrity

Warning: Since the seed is sent back from the browser to the server during a refresh, it is technically possible for a malicious user to modify its content before it reaches getRefreshedData.

  • Anticipate Tampering: For sensitive data, do not blindly trust the content of the seed to filter access.
  • Server-Side Verification: Always use the req object (which contains the authenticated session) to validate that the current user has sufficient permissions to access the data corresponding to the seed.
  • Verified Roles: Note that while the seed can be manipulated, the user's role and identity (accessible via req) are securely verified by JopiJS and cannot be forged.

5. Why This Architecture?

Automatic Merging

When getRefreshedData returns the items array, JopiJS performs a Deep Merge based on the itemKey.

  • If an item in the new list has a matching idProduct, its fields are updated.
  • If an item exists in the browser but is missing from the new list, it is removed.

Drastic SQL Optimization

This approach allows you to optimize your database load as your site grows by separating static from volatile data.

The Problem: Why Grouping is Hard

In many traditional applications, especially those with flexible schemas like WooCommerce (WordPress), data is stored in many different tables or using an EAV (Entity-Attribute-Value) pattern (like the wp_postmeta table).

Because the logic is often fragmented into various plugins or hooks, it is extremely difficult to write a single "clean" SQL query to fetch everything at once. Developers often fall back to fetching data product by product inside a loop:

The 400+ Queries Problem (WooCommerce Example)
-- For EACH of the 50 products, several queries are fired:
SELECT meta_value FROM wp_postmeta WHERE post_id = 101 AND meta_key = '_price';
SELECT meta_value FROM wp_postmeta WHERE post_id = 101 AND meta_key = '_stock_status';
SELECT meta_value FROM wp_postmeta WHERE post_id = 101 AND meta_key = '_weight';
-- ... repeated 50 times!

Result: 50 products × 8 queries = 400 database roundtrips. This kills performance and bottlenecks your CPU.

The JopiJS Way: Optimized Delta

With JopiJS, the heavy data is already in the HTML Cache. During the background refresh, we only want the updates. Because we are targeting only 1 or 2 specific values, we can finally write the grouped queries that were previously too complex to integrate.

Even with WooCommerce's wp_postmeta structure, we can fetch updates for all 50 products in just one or two lightning-fast queries:

JopiJS Optimized Refresh (wp_postmeta)
-- Fetch ALL prices for ALL 50 products in one go
SELECT post_id, meta_value as price 
FROM wp_postmeta 
WHERE meta_key = '_price' 
AND post_id IN (101, 102, 103, ...);

-- Fetch ALL stocks for ALL 50 products in one go
SELECT post_id, meta_value as stock 
FROM wp_postmeta 
WHERE meta_key = '_stock' 
AND post_id IN (101, 102, 103, ...);
MetricTraditional LoopJopiJS SWR Refresh
Number of Queries400+ (Loop based)1 or 2 (Grouped)
SQL ComplexitySimple but repeatedTargeted & Efficient
DB PressureExtremely HighMinimal
Speed1s - 3s10ms - 50ms

The Strategy: By serving the "stale" content from cache, JopiJS buys you the time to perform a highly optimized background refresh. You move from a fragmented, expensive data fetching logic to a clean, targeted architecture.


6. Disabling the Cache

If a page contains extremely sensitive data that should never be cached or served stale, place an empty file named autoCache.disable in the route folder.

This will force the server to execute the full data logic on every request and bypass the static HTML server.