Fizz supports machine-scoped data clients so state handlers can call app SDKs directly without bouncing through global output listeners.
Use this page when you want:
- typed client access in handlers via
utils.clients - clean dependency injection for runtime setup
- easy mocking in tests
customJSONAsync(...)pipelines backed by app clients
fetch-based effects are useful, but many apps use SDKs like OpenAPI clients, GraphQL clients, or service modules. Data clients let the machine call those dependencies directly while keeping transitions explicit and testable.
Declare client types in the machine definition (or fluent machine builder), then inject concrete implementations at runtime.
import { createMachine } from "@tdreyno/fizz"
type ApiClient = {
getProfile: (options: { signal: AbortSignal; userId: string }) => Promise<{
id: string
name: string
}>
}
const machine = createMachine<
typeof states,
typeof actions,
typeof outputActions,
unknown,
Record<string, never>,
{ apiClient: ApiClient }
>({
actions,
outputActions,
states,
})Fluent machine builder version:
import { machine } from "@tdreyno/fizz/fluent"
const ProfileMachine = machine("ProfileMachine")
.withClients<{ apiClient: ApiClient }>()
.withStates({ Loading, Loaded, Failed })
.withActions(actions)No .build() call is required.
Access clients from state handler utils and keep side effects in explicit async/effect helpers.
import { customJSONAsync, state } from "@tdreyno/fizz"
const Loading = state<
Enter,
{ userId: string },
string,
string,
string,
Record<string, unknown>,
{ apiClient: ApiClient }
>({
Enter: (data, _, { clients }) =>
customJSONAsync(signal =>
clients.apiClient.getProfile({
signal,
userId: data.userId,
}),
).chainToAction(profileLoaded, profileFailed),
})Fluent state version:
import { state } from "@tdreyno/fizz/fluent"
const Loading = state<{ userId: string }>("Loading")
.withClients<{ apiClient: ApiClient }>()
.onEnter((data, _, { clients }) =>
customJSONAsync(signal =>
clients.apiClient.getProfile({
signal,
userId: data.userId,
}),
).chainToAction(profileLoaded, profileFailed),
)Inject clients when creating a runtime.
import { createRuntime } from "@tdreyno/fizz"
const runtime = createRuntime(machine, initialState, {
clients: {
apiClient,
},
})React integration:
const context = useMachine(machine, initialState, {
clients: {
apiClient,
},
})Data clients are easy to mock because they are plain injected dependencies.
const apiClientMock = {
getProfile: jest.fn().mockResolvedValue({
id: "u-1",
name: "Ada",
}),
}
const runtime = createRuntime(machine, initialState, {
clients: {
apiClient: apiClientMock,
},
})Benefits:
- no global singleton setup
- no output wiring just to reach app services
- direct assertions on client calls in unit tests
For command-driven flows (commandEffect(...)), harness tests can also inject both clients and derived command handlers.
import { commandHandlersFromClients } from "@tdreyno/fizz"
import { createTestHarness } from "@tdreyno/fizz/test"
type Commands = {
notesEditor: {
setDocument: {
payload: { document: string }
result: { saved: true }
}
}
}
const clients = {
notesEditor: {
setDocument: jest.fn(() => ({ saved: true as const })),
},
}
const harness = createTestHarness({
history: [Editing({ status: "idle" })],
internalActions: { applyClicked, applySucceeded },
clients,
commandHandlers: commandHandlersFromClients<Commands>(clients),
})
await harness.run(applyClicked({ document: "Hello" }))
expect(clients.notesEditor.setDocument).toHaveBeenCalled()- Use
requestJSONAsync(...)when Fizz should own transport and JSON parsing. - Use
customJSONAsync(...)when your client already returns parsed data. - Use
startAsync(...)for non-request promise workflows.