Skip to content

Show skeletons while nested data loads

A deep page usually loads in layers: the session first, then the list, then the item. This guide gives every layer its own skeleton, so each part of the screen appears as soon as its own data is ready and the layers already on screen stay mounted.

For a cold visit to /registry/42:

MomentOn screen
session check runningfull-page skeleton
session ready, list loadsapp shell + registry skeleton
list ready, item loadsapp shell + registry + item skeleton
item readyapp shell + registry + inspection page

The guide assumes a router already rendering React views — see Build your first router if you don't have one yet. Every snippet below is one file, docs/how-to/nested-loading-skeletons.tsx, which runs as a test in the React package suite.

1. Declare one route per URL level

Nest routes with parent: the paths compose into /registry/:inspectionId, and opening the child opens its parents.

tsx
import { createRoute, createRouter } from '@effector/router';

export const appRoute = createRoute({ path: '/' });
export const registryRoute = createRoute({
  path: '/registry',
  parent: appRoute,
});
export const inspectionRoute = createRoute({
  path: '/:inspectionId',
  parent: registryRoute,
});

export const router = createRouter({
  routes: [appRoute, registryRoute, inspectionRoute],
});

2. Load each level with its own effect

One effect per level, fetching only what that level renders. The levels load in parallel, so a deep link does not pay for a request waterfall.

tsx
import { createEffect, createStore } from 'effector';

export interface Session {
  userName: string;
}

export interface RegistryEntry {
  id: string;
  title: string;
}

export interface Inspection {
  id: string;
  title: string;
  status: string;
}

export const checkSessionFx = createEffect<void, Session>(async () => {
  const response = await fetch('/api/session');
  return response.json();
});

export const loadRegistryFx = createEffect<void, RegistryEntry[]>(async () => {
  const response = await fetch('/api/registry');
  return response.json();
});

export const loadInspectionFx = createEffect(
  async ({ params }: { params: { inspectionId: string } }) => {
    const response = await fetch(`/api/inspections/${params.inspectionId}`);
    return response.json() as Promise<Inspection>;
  },
);

export const $session = createStore<Session | null>(null).on(
  checkSessionFx.doneData,
  (_, session) => session,
);

export const $registry = createStore<RegistryEntry[]>([]).on(
  loadRegistryFx.doneData,
  (_, entries) => entries,
);

export const $inspection = createStore<Inspection | null>(null).on(
  loadInspectionFx.doneData,
  (_, inspection) => inspection,
);

loadInspectionFx is called with the route payload, so the item id comes from params — there is no extra store to keep in sync with the URL.

3. Gate every level behind chainRoute

chainRoute derives a route that opens only once its preparation resolves and reports $isPending while it runs. That pending state is what a skeleton hangs on.

tsx
import { chainRoute } from '@effector/router';

export const authenticated = chainRoute({
  route: appRoute,
  beforeOpen: checkSessionFx,
});

export const registryReady = chainRoute({
  route: registryRoute,
  beforeOpen: loadRegistryFx,
});

export const inspectionReady = chainRoute({
  route: inspectionRoute,
  beforeOpen: loadInspectionFx,
});

4. Write a page and a skeleton per level

Parents render an Outlet where the next level appears. Pages read the data their level waited for.

tsx
import { useUnit } from 'effector-react';
import { Outlet } from '@effector/router-react';

function AppShell() {
  const session = useUnit($session);

  return (
    <div>
      <header>Signed in as {session?.userName}</header>
      <Outlet />
    </div>
  );
}

function AppShellSkeleton() {
  return <div>Signing in…</div>;
}

function RegistryPage() {
  const entries = useUnit($registry);

  return (
    <section>
      <h1>Registry</h1>
      <ul>
        {entries.map((entry) => (
          <li key={entry.id}>{entry.title}</li>
        ))}
      </ul>
      <Outlet />
    </section>
  );
}

function RegistrySkeleton() {
  return <section>Loading registry…</section>;
}

function InspectionPage() {
  const inspection = useUnit($inspection);

  if (!inspection) return null;

  return (
    <article>
      <h2>{inspection.title}</h2>
      <p>Status: {inspection.status}</p>
    </article>
  );
}

function InspectionSkeleton() {
  return <article>Loading inspection…</article>;
}

function InspectionPlaceholder() {
  return <article>Pick an inspection</article>;
}

function NotFound() {
  return <p>Not found</p>;
}

5. Nest the views and give each one a loading

Bind every view to the chained route, nest them with children to mirror the route nesting, and declare what each level shows while its own data loads.

tsx
import { createRouteView, createRoutesView } from '@effector/router-react';

export const RoutesView = createRoutesView({
  routes: [
    createRouteView({
      route: authenticated,
      view: AppShell,
      loading: AppShellSkeleton,
      children: [
        createRouteView({
          route: registryReady,
          view: RegistryPage,
          loading: RegistrySkeleton,
          children: [
            createRouteView({
              route: inspectionReady,
              view: InspectionPage,
              loading: InspectionSkeleton,
              closed: InspectionPlaceholder,
            }),
          ],
        }),
      ],
    }),
  ],
  otherwise: NotFound,
});

Render RoutesView inside RouterProvider as usual. That is the whole mechanism:

  • each loading renders while that level's chain is pending, in the slot its view would occupy — the top level in the routes view, the deeper ones in their parent's Outlet;
  • an opened view always wins over a fallback, so a level already on screen is never replaced by a skeleton when a child reloads;
  • the otherwise of createRoutesView renders only when no view claims the slot, which is why the not-found screen no longer flashes mid-navigation.

Variations

The parts — view, loading, closed, children, chainRoute, $isPending — are separate on purpose. A few adjacent goals and the shape each one takes.

Fill the slot when nothing is selected

/registry alone leaves the innermost Outlet empty. closed gives that slot an owner:

tsx
createRouteView({
  route: inspectionReady,
  view: InspectionPage,
  loading: InspectionSkeleton,
  closed: () => <article>Pick an inspection</article>,
});

Keep the frame, replace only the inner part

Chrome in the parent view, skeleton on the level that actually waits. The parent is never re-rendered by the child's swap:

tsx
createRouteView({
  route: registryReady,
  view: RegistryPage, // renders <Outlet />
  children: [
    createRouteView({
      route: inspectionReady,
      view: InspectionPage,
      loading: InspectionSkeleton,
    }),
  ],
});

Swap without remounting the subtree

loading replaces one component with another, so that branch remounts. To keep component state across the wait, drive the skeleton from $isPending inside a single component instead:

tsx
function RegistryPage() {
  const pending = useUnit(inspectionReady.$isPending);

  return (
    <section>
      <h1>Registry</h1>
      {pending ? <InspectionSkeleton /> : <Outlet />}
    </section>
  );
}

Show the ready child inside the parent skeleton

Levels prepare in parallel, so a child can be ready before its parent. An <Outlet /> inside loading streams in whatever is already there:

tsx
createRouteView({
  route: authenticated,
  view: AppShell,
  loading: () => (
    <div>
      Signing in…
      <Outlet />
    </div>
  ),
  children: [registryView],
});

Keep the current page instead of showing a skeleton

chainRoute prepares after the URL commits, so the previous route is already closed by then. To keep the old page on screen while the next one loads, hold the transition before it commits with beforeNavigate:

tsx
const holding = beforeNavigate({
  controls,
  to: inspectionRoute,
  filter: () => true,
});

sample({ clock: holding.started, target: loadInspectionFx });
sample({ clock: loadInspectionFx.done, target: holding.proceed });

Report the wait in the chrome that stays visible:

tsx
sample({ clock: loadInspectionFx.pending, target: topProgress.toggled });

Send the visitor elsewhere instead of loading

The same hold with a different ending — nothing reaches history:

tsx
const guard = beforeNavigate({
  controls,
  to: adminRoute,
  filter: $unauthorized,
});

sample({
  clock: guard.started,
  target: redirect({ to: signInRoute, replace: true }),
});

Split a level into its own chunk

Swap the level for createLazyRouteView and keep the same loading — it covers the chunk request as well as the pending route:

tsx
createLazyRouteView({
  route: inspectionReady,
  view: () => import('./inspection-page'),
  loading: InspectionSkeleton,
});

Keep one layout across a whole section

withLayout groups pages that share a shell: the layout instance survives while views from the same group swap, including while one shows a fallback.

tsx
createRoutesView({
  routes: withLayout(AppLayout, [registryView, settingsView]),
});

Released under the MIT License.