TanStack Start
TanStack Start is full-stack React on TanStack Router. Its key property for effector: route loaders are isomorphic — they run on the server for the first render and on the client for subsequent navigations. That makes the integration tiny: run the page's entry event in the loader, return serialize(scope) as loader data, and let the router's built-in dehydration carry it across.
The model is identical to the Next.js recipe — page entry events, queries with a stable name (their stores ship explicit sids, so no effector babel/SWC plugin is needed for the library's state), your own stores with explicit sids.
npm i effector effector-react effector-refetch1. The loader: fork → allSettled → serialize
Loader data must be serializable — serialize(scope) returns plain JSON, so the router dehydrates/rehydrates it automatically with the rest of the loader data:
// src/routes/users.tsx
import { createFileRoute } from '@tanstack/react-router';
import { allSettled, fork, serialize } from 'effector';
import { pageStarted } from '../model';
export const Route = createFileRoute('/users')({
validateSearch: (search) => ({ q: (search.q as string) ?? '' }),
loaderDeps: ({ search }) => ({ q: search.q }),
loader: async ({ deps }) => {
const scope = fork();
await allSettled(pageStarted, { scope, params: deps });
return { values: serialize(scope) };
},
component: UsersPage,
});/users?q=Marg renders on the server with the filter already applied. On a client-side navigation the same loader runs in the browser — a fresh fork, the query fetches client-side, and the page gets its values the same way. One code path for both.
2. The component: scope from loader data
import { useMemo } from 'react';
import { fork } from 'effector';
import { Provider } from 'effector-react';
function UsersPage() {
const { values } = Route.useLoaderData();
const scope = useMemo(() => fork({ values }), [values]);
return (
<Provider value={scope}>
<UsersScreen />
</Provider>
);
}First paint arrives with $data filled and status: 'done' — no skeleton, no refetch on mount. UsersScreen is the usual useUnit view, zero useState/useEffect (see the Next.js recipe).
3. Detail routes
// src/routes/users.$id.tsx
export const Route = createFileRoute('/users/$id')({
loader: async ({ params }) => {
const scope = fork();
await allSettled(userPageStarted, { scope, params: { id: Number(params.id) } });
return { values: serialize(scope) };
},
component: UserPage,
});Notes
- Server-only work belongs in server functions. The loader is isomorphic, so anything inside it ships to the client bundle. If the effect needs secrets or a DB, wrap that part in
createServerFnand call it from the query's effect — the query machinery (retry, cache, concurrency, abort) still applies around it. - Per-route scope: this minimal setup forks per route, so client-only state outside the serialized values resets on navigation. For a persistent client scope, keep a module singleton and merge loader values into it instead of re-forking.
- The cache layer (
$queryCache+dehydrate/hydrate) composes as in SSR & testing: put the adapter into the loader's fork and returndehydrate(cache)alongsidevalues.