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 * as  from "effect/http/HttpClient";

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

// Not the app's API client: its session plugin waits for this refresh.
const  = .({ :  }).(
  // No tracing headers, as for the API client in step 6.
  .(., false),
);

/** 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. The same registration types the hooks from @monarcode/session-kit/react:

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

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

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

declare module "@monarcode/session-kit" {
  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")
      : .(),
  ),
);

A client method that Auth doesn't wrap, such as auth.unlock(), rejects with an AuthError. Auth.fromAuthError turns it into an AuthFailure. Anything else it's given, such as a network error from createAuthFetch, is a defect.

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 "@monarcode/session-kit/react";

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 {  } from "@monarcode/session-kit/react";
import * as  from "effect/Exit";
import * as  from "effect/reactivity/AsyncResult";
import * as  from "effect/Schema";

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

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

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

export function () {
  const  = ();
  const { ,  } = ();

  async function () {
    const  = await ({
      // DummyJSON's documented test user.
      : { : "emilys", : "emilyspass" },
    });
    // A failed request shows in `result`.
    if (.()) return;
    const { , , ... } = .;
    await .({ , ,  });
  }

  return (
    <
      ={.}
      ={() => void ().(.)}
    >
      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). Each framework's adapter gives one request's session (RequestAuth), 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",
  : ,
  ,
  : ({ ,  }) => (, ),
});

/** One request's session, as your framework's adapter gives it. */
export type  = <typeof .>;

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 * 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 type {  } 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 request's `session`. */
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 request's session, in the server function, action or handler:

src/server/profile.ts
import {  } from "../user.js";
import type {  } from "./auth.js";
import {  } from "./run.js";

// TanStack Start: startAuth(serverAuth). React Router:
// routerAuth(serverAuth)(context). Next.js, from
// nextSession(serverAuth, { cookies, redirect }): await session.writable()
// in a Server Function, or await session.readOnly() in a Server Component.
export const  = (: ) =>
  (, );

Sign in on the server

On a request's session, sign in with its own signIn, which takes the tokens and the user, and update the user with its own updateUser. Auth.signIn and Auth.updateUser take what the browser client's methods do, so they aren't available there. Ask your backend with an Effect, then seal what it returns:

src/server/sign-in.ts
import {  } from "../user.js";
import type {  } 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 * as  from "effect/http/FetchHttpClient";
import * as  from "effect/Layer";

import type {  } from "./auth.js";
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 needs clientCredentials: true in createServerAuth, or the mirror's credentials option. 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, such as its revalidate and signIn, read the request's session directly, 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.
  • Requests that can't set cookies, such as a Next.js Server Component's. Auth.session returns the cookie's session as it is. Auth.credentials and sessionBearer fail with REFRESH_FAILED once the access token has expired, or after a 401, since nothing new can be saved. Refresh first, in middleware or Proxy, as the framework guides do. Sign out in an action or route handler.
  • Only Auth's Effects. A request's session isn't your browser client, so don't reach for client methods through Auth.use there.
  • Auth.layer binds a session from startAuth to the request that's current when it's called, so call it per request, as requestLayer does.

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, a request's session signs in and updates the user.

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