Skip to content

Content rendering

When you create an atom with croctContent, the library manages the full lifecycle of fetching, caching, and updating personalized content. This page explains how each piece works so you can build reliable experiences around it.

Every atom created by croctContent holds a state object with two properties: stage and content. The stage tells you where the content came from, and the content is the actual data to render.

There are three stages:

StageMeaning
initialThe atom holds the fallback content you provided and hasn’t finished its first fetch yet.
loadedPersonalized content was successfully fetched from Croct.
fallbackThe fetch failed and the atom fell back to the content you provided.

You can use the stage to adjust your UI. For example, you might show a loading indicator during initial, or log an analytics event when content reaches loaded:

src/components/Banner.tsx
import { useStore } from '@nanostores/react';
import { bannerContent } from '../stores/banner';
export function Banner() {
const banner = useStore(bannerContent);
if (banner.stage === 'initial') {
return <BannerSkeleton />;
}
return (
<section>
<h1>{banner.content.title}</h1>
<p>{banner.content.subtitle}</p>
</section>
);
}

The atom follows a predictable state machine:

initial ──fetch succeeds──▶ loaded
│ │
│ fetch fails │ refresh succeeds
▼ ▼
fallback ─refresh succeeds─▶ loaded

Two important guarantees:

  1. Content never disappears — The atom always holds renderable content, regardless of the stage.
  2. Loaded content is sticky — Once the atom reaches the loaded stage, it never reverts to initial or fallback, even if a subsequent refresh fails. The last successfully loaded content is preserved.

When the atom is in the loaded stage, a metadata property is available alongside the content. This contains information from the Croct API about the content delivery, such as the experiment and experience that produced it.

const banner = useStore(bannerContent);
if (banner.stage === 'loaded') {
console.log(banner.metadata);
// { experimentId: '...', experienceId: '...', ... }
}

The metadata property is only present in the loaded stage. In initial and fallback stages, it is undefined.

Croct Nanostores is designed to never break your UI, even when things go wrong:

  • Fetch failures are silent — If the initial fetch or any refresh fails, the error is logged to the console but never thrown. Your component keeps rendering with the fallback content.
  • Loaded content is preserved — Once personalized content has been successfully loaded, a failed refresh does not replace it. The atom stays in the loaded stage with the last good content.
  • Fallback is always valid — Because you provide the fallback content at atom creation time with the same type as the slot schema, the UI always has structurally valid data to render.

By default, atoms persist their state to localStorage. This means that when a user returns to your site, they immediately see the last personalized content instead of the fallback — even before the new fetch completes.

The storage key follows the pattern croct-nano|{slotId}:

croct-nano|home-banner@1

If you set the timeout option on the atom, persistence is disabled. The atom uses an ephemeral in-memory store instead, and every page load starts from the fallback content:

import { croctContent } from 'croct-nanostores';
const flashSale = croctContent(
'flash-sale@1',
{
headline: 'Sale coming soon',
discount: 0,
},
{
timeout: 3000, // 3-second fetch timeout, no persistence
},
);

Atoms automatically refresh when user behavior changes. The library listens for tracking events from the Croct SDK and triggers a content refresh so your UI stays in sync with the user’s current context.

import { croct } from 'croct-nanostores';
croct.plug({
appId: '<YOUR_APP_ID>',
plugins: ['auto-refresh-atom'],
});

You can opt-out of this behavior by disabling the auto-refresh-atom plugin on your croct.plug() call:

src/croct.ts
import { croct } from 'croct-nanostores';
croct.plug({
appId: '<YOUR_APP_ID>',
plugins: {
'auto-refresh-atom': false,
},
});

The following events cause all active atoms to refresh:

EventTypical trigger
userSignedInUser logs in
userSignedUpUser creates an account
userSignedOutUser logs out
userProfileChangedProfile attributes are updated
sessionAttributesChangedSession-level attributes change
orderPlacedUser completes a purchase
cartModifiedItems added to or removed from cart
interestShownUser expresses an interest
eventOccurredAny custom event is tracked

When a tracked event fires, the library doesn’t refresh immediately. Instead, it uses a cascade strategy aligned with Croct’s SLAs for event propagation. Since a single event on the server can trigger a chain of derived events (for example, a userSignedUp event may update user attributes, which in turn may match a new audience), the library refreshes three times — once per maximum link in the derivation chain:

  1. First refresh at 500 ms after the event
  2. Second refresh at 1,000 ms after the first
  3. Third refresh at 1,500 ms after the second

Each refresh captures any newly derived state, ensuring personalized content fully reflects the latest user data by the time the cascade completes. If the content didn’t change across refreshes, no subscriber will be called and no component wil re-render.

Only atoms that are currently mounted (subscribed to by at least one component) are refreshed. When a component unmounts and no other subscribers remain, the atom is removed from the active set and excluded from future refreshes.

This means you don’t pay for refreshing content that isn’t being displayed.

The attributes option on croctContent supports Nanostores atoms at any level of the object tree, and preferredLocale accepts a ReadableAtom<string> in addition to a plain string. The library recursively resolves all atoms and subscribes to them — whenever any embedded atom updates, the content automatically refreshes with the resolved values. This lets you tie individual personalization parameters to reactive application state without manually calling refresh().

src/stores/banner.ts
import { atom } from 'nanostores';
import { croctContent } from 'croct-nanostores';
const $currency = atom('USD');
const $segment = atom('returning');
export const bannerContent = croctContent(
'home-banner@1',
{
title: 'Welcome',
subtitle: 'Explore our latest collection',
ctaLabel: 'Shop now',
ctaLink: '/products',
},
{
attributes: {
currency: $currency,
segment: $segment,
},
},
);

When either atom is updated, the content refreshes with the new resolved attributes:

// The banner automatically refreshes with the new currency
$currency.set('BRL');

Atoms can appear at any nesting level — at the root wrapping the entire object, on individual leaf values, or nested arbitrarily deep:

// Atom at the root
croctContent('slot@1', fallback, {
attributes: atom({ currency: 'USD' }),
});
// Atom on a leaf value
croctContent('slot@1', fallback, {
attributes: { currency: atom('USD') },
});
// Atom nested deeper
croctContent('slot@1', fallback, {
attributes: { user: { currency: atom('USD') } },
});

Internally, the library wraps the attributes value with a resolved atom that recursively walks the object tree, resolves every Nanostores atom it finds, and subscribes to all of them. The resulting flat object is passed to croct.fetch(). If you pass a plain object with no atoms, it behaves as a static value — attributes are sent with every fetch but don’t trigger refreshes on their own.

On each change, the library compares the new resolved attributes with the last fetched attributes by reference. If they are the same object, the fetch is skipped. This means the refresh only fires when an embedded atom produces a new value.

Use a Nanostores atom for attributes when the attributes depend on application state that can change during the user’s session:

  • Currency or region picker — fetch region-specific personalization
  • Locale selector — pass a reactive preferredLocale atom and content refreshes when the user switches languages
  • A/B test flags — pass experiment assignments that may change at runtime
  • User segments — adjust content based on dynamic audience classification
  • Derived state — use a computed atom to combine multiple stores into a single attributes object

For attributes that are static for the lifetime of the page (like a page ID or a hardcoded segment), a plain object is simpler and works just as well.

Every atom exposes a refresh() method that returns a promise. You can call it to force a fresh fetch from Croct at any time:

import { bannerContent } from '../stores/banner';
// Refresh and wait for the result
await bannerContent.refresh();
// Or fire-and-forget
bannerContent.refresh();

The refresh follows the same fault tolerance rules: if it fails, loaded content is preserved, and the error is logged to the console.

Because all frameworks subscribe to the same underlying Nanostores atom, a change to the atom’s value is reflected everywhere simultaneously. This is particularly useful in micro-frontend architectures or Astro applications where multiple frameworks coexist on the same page.

For example, if a React component tracks a user interest that triggers a refresh, a Vue component subscribed to the same atom sees the updated content at the same time — no additional wiring needed.

See the live demo for a working example of this cross-framework reactivity.