Client Plugins
Client plugins let us customize the runtime behavior of our application’s documents — integrating with a logging service, adding retry logic, or even adding support for entirely new network capabilities like Live Queries.
Unstable API
The client plugin API is still considered unstable. We reserve the ability to change its structure with any minor version update. By building a plugin, you acknowledge this and accept the responsibility of not breaking your users’ projects.
Overview
Every document in a Houdini app is backed by an observable value called a “Document Store”. The store holds the latest value of the document and sends new queries to update its state. Client plugins modify this structure by hooking into five phases of the request pipeline:
start— runs at the beginning of every request, regardless of cachebeforeNetwork— runs when there was no cache hit and a network request is about to be madenetwork— performs the actual network requestafterNetwork— runs after the network request but before the cache processes the resultend— runs at the end of the request regardless of whether data came from cache or network
For some documents, beforeNetwork, network, and afterNetwork are short-circuited when the cache policy allows it. Think of the cache as a gatekeeper that decides whether a request can be resolved before reaching the network phases.
While preparing a request, plugins are iterated in the order they are passed to the client. Once a result is provided, its value flows in reverse through the plugins we’ve visited, giving each a chance to modify the response.
Writing a Plugin
A client plugin is a function that returns an object with hooks for the phases we want to intercept:
import type { ClientPlugin } from '$houdini'
const sayHello: ClientPlugin = () => { return { start(ctx, { next }) { console.log("Hello world!") next(ctx) } }}Then pass it to the client:
import { HoudiniClient } from '$houdini'
export default new HoudiniClient({ url: "...", plugins: [sayHello]})That’s the basic shape. Now let’s look at a more useful example — a retry plugin that re-runs a query when the response contains errors:
import type { ClientPlugin } from '$houdini'
const retry: ClientPlugin = () => { return { end(ctx, { value, next, resolve }) { if (value.errors && value.errors.length > 0) { // start the pipeline over next(ctx) return } resolve(ctx) } }}We’ll want to add a retry limit in practice to avoid an infinite error loop, but this shows the core pattern: next sends the request forward (toward the server), resolve sends the result backward (toward the user).
Enter vs Exit Hooks
The five phases split into two directions.
Enter hooks (start, beforeNetwork, network) carry information toward the server. We call next to pass to the next plugin, or resolve to short-circuit the chain and start returning a value:
import type { ClientPlugin } from '$houdini'
const simpleFetch: ClientPlugin = () => { return { async network(ctx, { resolve }) { const result = await fetch('...', { body: JSON.stringify({ query: ctx.text }) }) resolve(ctx, { data: result.data }) } }}A few rules for enter hooks:
- At least one hook in the chain must call
resolve— otherwise the pipeline hangs indefinitely - We can call both
resolveandnextin the same hook; prefer callingresolvefirst when it makes sense resolverequires a fullQueryResult(see Type Definitions)
Exit hooks (afterNetwork, end) process a value as it returns from the pipeline. We use resolve to keep the chain flowing back to the user:
import type { ClientPlugin } from '$houdini'
const logErrors: ClientPlugin = () => { return { end(ctx, { value, resolve }) { if (value.errors && value.errors.length > 0) { console.warn('encountered errors:', value.errors) } resolve(ctx) } }}Exit hook rules:
- We don’t have to pass a value to
resolve— the last known value is used automatically - Exit hooks that never call
resolvecreate a pipeline black hole; no data reaches the user
Choosing a Phase
If it’s unclear which phase to use, work through these questions:
- Am I making a network request? → use
network - Do I want to inspect the request before it hits the API? → use
start(if it must always run) orbeforeNetwork(if it should skip when the cache responds) - Do I want to inspect the response? → use
end(if it must always run) orafterNetwork(if cached responses should skip it)
Stateful Plugins
State within a single request
To share state between phases of the same request, store values on ctx.stuff:
import type { ClientPlugin } from '$houdini'
const timer: ClientPlugin = () => { return { start(ctx, { next }) { ctx.stuff = { ...ctx.stuff, startTime: new Date() } next(ctx) }, end(ctx, { resolve }) { const diff = Math.abs(new Date() - ctx.stuff.startTime) console.log(`This request took ${diff}ms`) resolve(ctx) } }}State between requests
To track state across multiple network requests, initialize it before returning the hooks object. Houdini calls the outer function once when the store is created:
import type { ClientPlugin } from '$houdini'
const trackVariables: ClientPlugin = () => { let lastVariables = {}
return { start(ctx, { next }) { ctx.variables = { ...lastVariables, ...ctx.variables } lastVariables = ctx.variables next(ctx) } }}Multiple Values
A store can receive multiple updates for a given set of inputs — subscriptions and live queries both push multiple results through the cache. Each payload travels through the full chain using the same resolve function. If the original request hasn’t resolved when a payload arrives, the promise resolves with that first value. This means we can call resolve inside event listeners to push multiple values.
Composing Plugins
A plugin can return any combination of hook objects, null, or arrays of hooks — useful for toggling functionality based on configuration:
import type { ClientPlugin } from '$houdini'import externalPlugin from 'third-party'
const conditional: ClientPlugin = (config) => () => { return [ { start(ctx, { next }) { next(ctx) } }, config.value ? externalPlugin : null ]}Type Definitions
The authoritative source is exported from $houdini. The definitions below may be slightly out of date — if there’s a discrepancy, the package wins.
type ClientPlugin = () => { start?: ClientPluginEnterPhase beforeNetwork?: ClientPluginEnterPhase network?: ClientPluginEnterPhase afterNetwork?: ClientPluginExitPhase end?: ClientPluginExitPhase
/** Called when the document store has no more subscribers */ cleanup?(ctx: ClientPluginContext): void | Promise<void>
/** Called when an inner plugin throws. Re-throw to propagate. */ catch?(ctx: ClientPluginContext, args: ClientPluginCatchHandlers): void | Promise<void>}
type ClientPluginEnterHandlers = { initialValue: QueryResult client: HoudiniClient updateState(updater: (old: QueryResult) => QueryResult): void next(ctx: ClientPluginContext): void resolve(ctx: ClientPluginContext, data: QueryResult): void variablesChanged: (ctx: ClientPluginContext) => boolean marshalVariables: (ctx: ClientPluginContext) => Record<string, any>}
type ClientPluginExitHandlers = { value: QueryResult initialValue: QueryResult client: HoudiniClient updateState(updater: (old: QueryResult) => QueryResult): void next(ctx: ClientPluginContext): void resolve: (ctx: ClientPluginContext, data?: QueryResult) => void variablesChanged: (ctx: ClientPluginContext) => boolean marshalVariables: (ctx: ClientPluginContext) => Record<string, any>}
type ClientPluginCatchHandlers = { error: unknown initialValue: QueryResult client: HoudiniClient updateState(updater: (old: QueryResult) => QueryResult): void next(ctx: ClientPluginContext): void resolve: (ctx: ClientPluginContext, data?: QueryResult) => void variablesChanged: (ctx: ClientPluginContext) => boolean marshalVariables: (ctx: ClientPluginContext) => Record<string, any>}
type ClientPluginContext = { config: ConfigFile text: string hash: string artifact: DocumentArtifact policy?: CachePolicy fetch?: Fetch variables?: Record<string, any> metadata?: App.Metadata | null session?: App.Session | null fetchParams?: RequestInit cacheParams?: { layer?: Layer notifySubscribers?: SubscriptionSpec[] forceNotify?: boolean disableWrite?: boolean disableRead?: boolean applyUpdates?: boolean } stuff: App.Stuff}
type QueryResult = { data: GraphQLObject | null errors: { message: string }[] | null fetching: boolean partial: boolean stale: boolean source: DataSource | null variables: _Input | null}