Skip to Content

Effect and effetch

Use the session as an Effect service, call your API with effetch, and cache each account's data with effetch-query.

In this guide, you use the session from an Effect app. You read it as a service, call your API with effetch, and cache each account's data with effetch-query. Two entry points do this:

  • @monarcode/session-kit/effect gives your Effects the session as the Auth service. It wraps the client you already have, so React, your router and your Effects share one session. Its Effects fail with a tagged AuthFailure, which Effect.catchTag handles like your own errors.
  • @monarcode/session-kit/effetch has the sessionBearer plugin. It sends the access token with each effetch call, and refreshes after a 401.

A complete app is in examples/tanstack-start-spa-effetch. Every export is listed in the reference for ./effect and ./effetch.

Before you begin

  • Read Getting started. This guide builds the same app with Effect Schemas and effetch. Refresh and Errors explain how the client renews and fails.
  • Know Effect's generators, services and layers.

Installation

To install the packages, run the following command:

npm install @monarcode/session-kit effect @monarcode/effetch

effect and @monarcode/effetch are optional peer dependencies of Session kit. ./effect and ./effetch need effect 4.1 or newer, and ./effetch also needs @monarcode/effetch.

To cache with effetch-query, also install effetch-query 0.1.0-rc.2 or newer, and its @effect/atom-react peer for React:

npm install @monarcode/effetch-query @effect/atom-react

1. Describe your backend

This is the backend from Getting started, as Effect Schemas: the user, the tokens, and the body DummyJSON explains a failure with. They're schemas only, with no client, so the rest of the app, and the server, import them from one place.

src/backend.ts
import * as  from "effect/Schema";

export const  = "https://dummyjson.com";

export const  = .({
  : .,
  : .,
  : .,
  : .,
});

/** The user, as Session kit's `user` option takes it. */
export const  = .();

export const  = .({
  : .,
  : .,
});

/** DummyJSON returns the user's fields beside the tokens. */
export const  = .({
  ....,
  ....,
});

export const  = .({ : . });

2. Refresh with effetch

The refresh gets an effetch client of its own, made with its own Fetch.make, for two reasons:

  • The app's API client waits for a refresh after a 401. A refresh sent through it would wait for itself.
  • The app's runtime is built on the session client this refresh belongs to, so the refresh can't run in it. It provides the FetchHttpClient effetch sends with itself.

refreshSession resolves null when the backend rejects the refresh token, and fails for anything else, as Refresh explains. The server uses it too:

src/refresh.ts
import * as  from "@monarcode/effetch";
import * as  from "effect/Effect";
import * as  from "effect/http/FetchHttpClient";

import { ,  } from "./backend.js";

// Not the app's API client: its session plugin waits for this refresh.
const  = .({ :  });

/** New tokens. `null` signs out; a failure keeps the session for a retry. */
export const  = (: string, : ) =>
  .(
    .(function* () {
      const  = yield* ;
      return yield* .("/auth/refresh", {
        : {  },
        : ,
      });
    }).(
      .("HttpError", () =>
        . === 401 || . === 403
          ? .(null)
          : .(),
      ),
      .(.),
    ),
    {  },
  );

3. Register the client

Create the client as usual, then register its type once, so every Effect carries your user:

src/auth.ts
import { ,  } from "@monarcode/session-kit";
import {  } from "@monarcode/session-kit/react";

import {  } from "./backend.js";
import {  } from "./refresh.js";

export const  = ({
  : "app",
  : ,
  : (),
  : ({ ,  }) => (, ),
});

export const { , ,  } =
  <typeof >();

declare module "@monarcode/session-kit/effect" {
  interface Register {
    : typeof ;
  }
}

4. Provide the service

Auth.layer provides the client as the Auth service. It doesn't mount the client: AuthProvider, SessionGate or the router adapters still do that. To provide it, create the layer once:

src/layer.ts
import {  } from "@monarcode/session-kit/effect";

import {  } from "./auth.js";

export const  = .();

In tests, provide a layer over another client instead.

5. Read the session

Auth.session is the current session. If the access token expired, it refreshes first. It fails with UNAUTHENTICATED when no one is signed in. For code that also runs signed out, Auth.sessionOption gives Option.none() instead:

src/greeting.ts
import {  } from "@monarcode/session-kit/effect";
import * as  from "effect/Effect";
import * as  from "effect/Option";

import {  } from "./layer.js";

export const  = ..(
  .(
    .({
      : () => "Welcome",
      : () => `Welcome back, ${..}`,
    }),
  ),
);

export const  = () =>
  .(.(, ));

6. Call your API with effetch

Make the app's API client with effetch's Fetch.make: the backend's base URL, its error body, and the plugins every call goes through. The sessionBearer plugin does what createAuthFetch does: it sends the access token, and refreshes after a 401.

Put sessionBearer last, so that a retry reads the current token. Give it the client's baseUrl, since the token goes only to that origin. Fetch.make then needs the Auth service, once:

src/api.ts
import * as  from "@monarcode/effetch";
import {  } from "@monarcode/effetch/plugins";
import {  } from "@monarcode/session-kit/effetch";
import * as  from "effect/Context";
import * as  from "effect/Effect";
import * as  from "effect/http/HttpClient";
import * as  from "effect/Layer";

import { ,  } from "./backend.js";

const  = .({
  : ,
  : ,
  : [(), ({ : , : true })],
}).(
  // Effect adds tracing headers to every request, which fail the preflight
  // of an API that doesn't allow them.
  .(., false),
);

export class  extends .<
  ,
  .<typeof >
>()("ApiClient") {}

/** The API client, over whichever `Auth` is provided. */
export const  = .(, );

Calls can fail with AuthFailure. When one client serves public and private endpoints, as this one does, give the plugin optional: true: a call made while signed out is then sent without a token, and a private endpoint answers with its own 401. A locked client still fails with LOCKED.

Effect code yields the client and calls its methods, such as get and post. The sign-in call skips the plugin with sessionBearer: false, because it's how the user gets a token. To read the current user and to sign in, add src/user.ts:

src/user.ts
import * as  from "effect/Effect";

import {  } from "./api.js";
import { ,  } from "./backend.js";

export const  = .(function* () {
  const  = yield* ;
  return yield* .("/auth/me", { :  });
});

export const  = (: string, : string) =>
  .(function* () {
    const  = yield* ;
    return yield* .("/auth/login", {
      : { ,  },
      : ,
      : { : false },
    });
  });

With another HTTP client, use Auth.credentials, the access token. It's kept apart from Auth.session, so code that only needs the user never holds a token. After a 401, Auth.renew(credentials) gets new credentials once for every caller.

7. Run Effects in one runtime

Compose the app's layers in one place: the API client, the session, and the FetchHttpClient effetch sends with, each made once:

src/runtime.ts
import * as  from "effect/http/FetchHttpClient";
import * as  from "effect/Layer";
import * as  from "effect/ManagedRuntime";

import {  } from "./api.js";
import {  } from "./layer.js";

const  = .(
  .(),
  .(.),
);

/** One runtime for the app's lifetime. */
export const  = .();
export type  = typeof ;

Run every Effect with this runtime, as in appRuntime.runPromise(currentUser), instead of providing AuthLive as step 5 does.

Handle failures

Every Auth Effect fails with AuthFailure. Its code is the client's AuthErrorCode, and its cause is the AuthError itself. Misuse, which the client reports as INVALID_OPTIONS, is a defect rather than a failure.

For example, to show "Guest" when no one is signed in:

src/account.ts
import {  } from "@monarcode/session-kit/effect";
import * as  from "effect/Effect";

export const  = ..(
  .(() => ..),
  .("AuthFailure", () =>
    . === "UNAUTHENTICATED"
      ? .("Guest")
      : .(),
  ),
);

Code outside Auth that rejects with an AuthError, such as createAuthFetch, converts it with Auth.fromAuthError.

Update the user

Auth.updateUser takes the user, or an Effect that fetches it. The session is captured before the Effect runs, so a profile fetched for one account fails with SESSION_CHANGED instead of landing on another. The Effect's own failure comes through unchanged:

src/profile.ts
import {  } from "@monarcode/session-kit/effect";

import {  } from "./user.js";

export const  = .();

Watch the session

Auth.changes is a Stream of the client's AuthState: the current state, then every change, until the stream ends:

src/watch.ts
import {  } from "@monarcode/session-kit/effect";
import * as  from "effect/Effect";
import * as  from "effect/Stream";

export const  = ..(
  .(() => .),
  .,
  .(() => .(`Auth is ${}`)),
);

Cache with effetch-query

effetch-query caches endpoints: a route and its schemas, defined with Fetch.endpoint, which it calls with the app's API client. Its queries and mutations run with the app's runtime, so components never run an Effect by hand. Give it the client's defaultError, which an endpoint can't read from the client:

src/query.ts
import {  } from "@monarcode/effetch-query";
import {  } from "@monarcode/effetch-query/effetch";
import type * as  from "effect/ManagedRuntime";

import {  } from "./api.js";
import {  } from "./backend.js";
import { , type  } from "./runtime.js";

declare module "@monarcode/effetch-query" {
  interface Register {
    readonly : .ManagedRuntime.<>;
  }
}

export const  = (, { :  });

export const  = () => .({ :  });

A cache must not outlive the account that filled it. Let QueryProvider own its client (make instead of client) and pass the session ID as resetKey. Signing out or switching accounts then disposes the client, with its cache and its requests in flight, and renders the tree with a new one:

src/query-provider.tsx
import type {  } from "react";

import {  } from "@monarcode/effetch-query/react";

import {  } from "./auth.js";
import {  } from "./query.js";

export function ({  }: { :  }) {
  const  = (() => . ?? "signed-out");
  return (
    < ={} ={}>
      {}
    </>
  );
}

Render it inside AuthProvider or SessionGate, above every Suspense boundary. A refresh, a failed refresh or a profile update keeps the sessionId, and so the cache. So does locking: queries that need a token fail with LOCKED until unlock(), then refetch as usual. Until a saved session is restored, the key is "signed-out", and the restore replaces that client.

A feature defines the endpoints it caches, and its components use the queries and mutations made of them:

src/features/account.tsx
import * as  from "@monarcode/effetch";
import { ,  } from "@monarcode/effetch-query/react";
import * as  from "effect/reactivity/AsyncResult";
import * as  from "effect/Schema";

import {  } from "../auth.js";
import { ,  } from "../backend.js";
import {  } from "../query.js";

const  = .(
  .("POST /auth/login", {
    : .({ : ., : . }),
    : ,
  }),
  { : { : false } },
);

const  = .(
  .("GET /auth/me", { :  }),
);

export function () {
  const  = ();
  const { ,  } = ();
  return (
    <
      ={.}
      ={() =>
        (
          // DummyJSON's documented test user.
          { : { : "emilys", : "emilyspass" } },
          {
            : ({ , , ... }) =>
              .({ , ,  }),
          },
        )
      }
    >
      Sign in
    </>
  );
}

export function () {
  const  = (());
  return .(, {
    : () => <>Loading…</>,
    : () => <>Couldn't load your profile.</>,
    : ({  }) => <>{.}</>,
  });
}

The owned client is for the browser and React Native. With server rendering, give QueryProvider a client per request instead, as effetch-query's README describes. Make it on the server over that request's session, as Render queries on the server shows.

On the server

With server rendering, server functions, actions and route handlers run Effects too, and the session they read is the cookie the server keeps (Server sessions). serverAuth.forRequest(jar) is one request's session, and Auth.layer takes it, so Auth.session, Auth.credentials and sessionBearer work there: sessionBearer sends the cookie's token, and saves a renewed one after a 401.

On the server, the session comes from createServerAuth, with the user schema the app registers and the same refresh. The server's files build on src/backend.ts, src/refresh.ts, src/api.ts and src/user.ts from the earlier steps.

In a server-rendered app, the registered client is the browser's createMirrorAuth client, not createAuth. Each framework's guide sets it up: TanStack Start, Next.js and React Router.

To create the server's session engine, add src/server/auth.ts:

src/server/auth.ts
import {  } from "@monarcode/session-kit/server";

import {  } from "../backend.js";
import {  } from "../refresh.js";

declare const : string; // from the environment

export const  = ({
  : "app",
  : ,
  ,
  : ({ ,  }) => (, ),
});

A request's session belongs to that request, and so does everything that uses it. Build those per request, and keep a runtime for what belongs to no user:

src/server/run.ts
import {  } from "@monarcode/session-kit/effect";
import type {  } from "@monarcode/session-kit/server";
import * as  from "effect/Effect";
import * as  from "effect/http/FetchHttpClient";
import * as  from "effect/Layer";
import * as  from "effect/ManagedRuntime";

import { type ,  } from "../api.js";
import {  } from "./auth.js";

/** Built once: what belongs to no user. */
const  = .(.);

/** Built per request: its session, and the API client that uses it. */
export const  = (: ) =>
  .(
    .(.(.())),
  );

/** Runs `effect` with the session of the request `jar` belongs to. */
export function <, >(
  : ,
  : .<, ,  | >,
): <> {
  return .(.(, ()));
}

The runtime has no Auth and no API client, so an Effect that needs them doesn't compile until it goes through runRequest, and nothing a service keeps outlives its request. Building them is cheap next to the request your API answers.

Each framework's adapter gives the jar, in the server function, action or handler:

src/server/profile.ts
import type {  } from "@monarcode/session-kit/server";

import {  } from "../user.js";
import {  } from "./run.js";

// TanStack Start: startJar(). React Router: routerJar(context).
// Next.js: nextJar(await cookies(), { writable: true }).
export const  = (: ) => (, );

Sign in on the server

Auth.signIn and Auth.updateUser aren't available on a request's session: the user comes in through serverAuth. Ask your backend with an Effect, then seal what it returns:

src/server/sign-in.ts
import type {  } from "@monarcode/session-kit/server";

import {  } from "../user.js";
import {  } from "./auth.js";
import {  } from "./run.js";

export async function (
  : ,
  : string,
  : string,
) {
  const { , , ... } = await (
    ,
    (, ),
  );
  return .(, { , ,  });
}

Render queries on the server

To render a page with its data, make a query cache per request, on the server, over that request's layer, and dispose of it once the page is sent:

src/server/query-client.ts
import {  } from "@monarcode/effetch-query";
import type {  } from "@monarcode/session-kit/server";
import * as  from "effect/http/FetchHttpClient";
import * as  from "effect/Layer";

import {  } from "./run.js";

export const  = (: ) =>
  .({
    : ().(.(.)),
  });

Give it no memoMap (Things to know says why). Prefetch before the page starts streaming, or refresh in middleware first, since a cookie set once streaming has begun is lost. In the browser, the cache is the owned one from Cache with effetch-query, over the mirror client.

Things to know

  • One registration. An app with server rendering registers its mirror client, and that registration types Auth on the server too: Auth.layer takes a request's session whose user is the registered one. In the browser, Auth.layer(mirror) is built where the mirror is created. Auth.signIn needs the mirror's signIn option there, sessionBearer its credentials option, and Auth.updateUser isn't available, since the user comes from the server.
  • Keep server modules free of the browser client. In this guide, the server's files import only src/backend.ts, src/refresh.ts, src/api.ts and src/user.ts, which create no Session kit client. Keep it that way in your app. A module the server imports, such as the one that defines ApiClientLive or the user schema, mustn't also create a browser client or its layer, or the server loads it too.
  • Name the services in runRequest's signature, as src/server/run.ts does, rather than inferring them from requestLayer. With a mirror registered, the registration depends on the server functions the mirror calls, which call runRequest, and TypeScript reports a circular type. For the same reason, the server functions the mirror is made from (its revalidate, signIn and signOut) read the session with serverAuth, not Auth.session.
  • Keep a request's layer off a shared memoMap, such as the runtime's or one passed to QueryClient.make. Layers are shared by identity there, so overlapping requests can get one build: a service that keeps Auth when it's built would then serve one request's session to another.
  • Read-only jars, such as a Next.js Server Component's: Auth.session returns the cookie's session as it is, while Auth.credentials and sessionBearer fail with REFRESH_FAILED once access has expired, and after a 401, which can't be renewed there unless the same request's writable jar already renewed it. Refresh in middleware or a proxy first, with serverAuth.refreshIfDue, as the framework guides do. Sign out in an action or route handler.
  • Only Auth's Effects. A request's session has only what Auth's Effects use, so don't reach for other client methods through Auth.use there.

What stays on the client

Auth covers what Effect code does with a session: reading it, signing in and out, and updating the user. Everything else stays on the client, which React and the routers already drive: lock and unlock, refresh and retry, mount, and the client's options. On the server, signing in and updating the user are serverAuth's.

Next steps

  • Server sessions: how the server keeps the session in a cookie, and refreshes it.
  • Locking: lock a native app's session. Auth.session and sessionBearer then fail with LOCKED until it's unlocked.
  • Other HTTP clients: the calls sessionBearer is built on, for any other client.

On this page