Skip to content

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 cache
  • beforeNetwork — runs when there was no cache hit and a network request is about to be made
  • network — performs the actual network request
  • afterNetwork — runs after the network request but before the cache processes the result
  • end — 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.

Diagram showing the 5 phases of the client plugin pipeline Diagram showing the 5 phases of the client plugin pipeline

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:

src/plugins/custom_plugin.ts
import type { ClientPlugin } from '$houdini'
const sayHello: ClientPlugin = () => {
return {
start(ctx, { next }) {
console.log("Hello world!")
next(ctx)
}
}
}

Then pass it to the client:

src/client.ts
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:

src/client.ts
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:

src/client.ts
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 resolve and next in the same hook; prefer calling resolve first when it makes sense
  • resolve requires a full QueryResult (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:

src/client.ts
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 resolve create a pipeline black hole; no data reaches the user

Choosing a Phase

If it’s unclear which phase to use, work through these questions:

  1. Am I making a network request? → use network
  2. Do I want to inspect the request before it hits the API? → use start (if it must always run) or beforeNetwork (if it should skip when the cache responds)
  3. Do I want to inspect the response? → use end (if it must always run) or afterNetwork (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:

src/client.ts
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:

src/client.ts
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:

src/plugins/custom_plugin.ts
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
}