Skip to content

Passing Inputs to Blocs

Blocs sometimes need external data — a user ID to load, an endpoint URL, a DOM ref for a canvas. Passing that data safely is trickier than it looks, because multiple components can share one instance and each wants to set something on it.

The three lanes — args, deps, and events — and the identity model below are the whole story. For the broader why behind shared, ref-counted instances see the Mental Model, and for the judgment of which lane to reach for see Best Practices.

LanePurposeKeys identity?LifetimeExample
argsTyped creation/config dataYes (structural hash or static key)Set once via init(args)userId, endpoint, filters
depsNon-serializable handlesNeverLive, per-consumer mergedinputRef, stable callback, emblaApi
eventsValues that change over the instance’s lifeN/ACalled from effectscubit.slidesChanged(v)

These map onto a familiar React split: args is like defaultValue (set at birth, identity-bearing), events is like value/onChange (a live channel owned by one component), and deps is the side channel for things that can’t be serialized at all.


args: construction data that keys the instance

Section titled “args: construction data that keys the instance”

When a bloc declares an Args type, useBloc requires you to pass args. They are:

  • Forwarded to the bloc’s init(args) method once, synchronously, before the first state snapshot — so initial state is correct on the first render, no flash.
  • Used to derive the instance key — different args ⇒ different instance. Same args ⇒ same instance.
  • A type error to omit when declared, or to pass when Args is void.
class UserCardCubit extends Cubit<UserCardState, { userId: string }> {
protected init(args: { userId: string }) {
// called once by the framework at creation, before the first snapshot
void this.loadUser(args.userId);
}
}
// args is required and type-checked
const [state] = useBloc(UserCardCubit, { args: { userId } });

Why this eliminates the override race: each distinct userId produces a distinct instance. Multiple components rendering the same userId share one instance and their args are by definition identical. There is nothing to race over.

By default, identity is the structural hash of all args (the same principle as atomFamily and TanStack Query’s queryKey). This is safe because args must be JSON-serializable — no refs or callbacks can accidentally destabilize the hash.

Override identity with a static key on the class when only a subset of args should key the instance, or when you want a human-readable key:

class DocumentCubit extends Cubit<
DocState,
{ docId: string; readonly: boolean }
> {
static key = (args: DocumentCubit['args']) => args.docId;
// `readonly` is config that rides along but does NOT fork instances
}

static key is declared once on the class, not repeated at every call site. It reads as a plain statement of what distinguishes one DocumentCubit from another.

You can also supply static key via the blac() decorator/config function:

const DocumentCubit = blac({ key: (args) => args.docId })(
class extends Cubit<DocState, { docId: string; readonly: boolean }> { ... }
);

See Configuration for the rest of the blac() options (keepAlive, equality, excludeFromDevTools) and Glossary for how args and static key relate.

To give each mount its own instance — disposed on unmount, never shared — include a per-mount unique value in args and declare a static key that uses it. The idiomatic source of a stable-per-mount value is React’s useId():

// private instance, own lifecycle, seeded with args
class FileUploadCubit extends Cubit<
UploadState,
{ endpoint: string; _id: string }
> {
static key = (a: FileUploadCubit['args']) => a._id;
}
const _id = useId();
const [state, cubit] = useBloc(FileUploadCubit, { args: { ...options, _id } });

Because useId() returns a different value per component instance (and the same value across that component’s re-renders), the derived key is unique per mount, so each mount gets a private bloc that lives and dies with it. The other args (endpoint) still seed init(args) — they ride along without forking instances because only _id keys identity.

Args participate in identity hashing. Refs, callbacks, DOM elements, and class instances cannot go in args — they belong in the deps lane (below). Passing non-serializable values produces an unstable hash (a new key every render) which will cause a new instance on every render.

args-keyed instances — live demo

Pick a key — each key maps to a distinct KeyCounterCubit instance. Increment a key's counter, then switch away and back — its count persists because the instance is still alive.

KeyCounterCubit("alpha")renders1

reads: state.count — instance keyed by args.key

0

Some things are genuinely non-serializable: a useRef container, a useCallback-stabilized handler, an Embla API instance handed in from outside. These live in the bloc’s third type parameter — its Deps — and obey these rules:

  • Never key identity — different refs don’t fork the instance.
  • Per-consumer merged — each component contributes its own slice; the bloc sees the union.
  • Read lazily — via this.deps.x at action time, may be undefined. Always guard.
  • Applied post-commit — never during render; no mid-render mutation.

The bloc side is declarative: declare the Deps shape and read this.deps.x lazily.

import type { RefObject } from 'react';
class FileUploadCubit extends Cubit<
UploadState,
{ endpoint: string }, // args → keys identity
{ inputRef?: RefObject<HTMLInputElement> } // deps → never keyed, read lazily
> {
protected init(args: { endpoint: string }) {
this.endpoint = args.endpoint;
}
openPicker() {
this.deps.inputRef?.current?.click?.(); // guard for absence
}
}
import { useEffect, useId, useRef } from 'react';
import { APPLY_DEPS, REMOVE_DEPS_OWNER } from '@blac/core';
import { useBloc } from '@blac/react';
function UploadButton({ endpoint }: { endpoint: string }) {
const inputRef = useRef<HTMLInputElement>(null);
const ownerId = useId(); // identifies THIS consumer's slice
// same endpoint → same instance, regardless of which ref is passed
const [state, cubit] = useBloc(FileUploadCubit, { args: { endpoint } });
useEffect(() => {
cubit[APPLY_DEPS](ownerId, { inputRef }); // contribute this consumer's slice
return () => cubit[REMOVE_DEPS_OWNER](ownerId); // withdraw it on unmount
}, [cubit, inputRef, ownerId]);
return <button onClick={() => cubit.openPicker()}>Choose file</button>;
}

ownerId (a useId() value, stable per mount) tags the slice so the engine knows which consumer wrote it and can withdraw exactly that slice on unmount.

Why an effect, not a render-time call

Deps must be applied after commit so a freshly mounted ref.current is populated and so mid-render mutation never happens. The effect runs post-commit; its cleanup runs before the consumer’s useBloc releases its ref, so the slice is withdrawn while the bloc is still alive. APPLY_DEPS/REMOVE_DEPS_OWNER are marked @internal today (a friendlier wrapper may land later), but this is the supported path.

Different components can contribute different slices of deps to the same instance. The rules are simple:

  1. Each consumer’s keys are shallow-merged into bloc.deps.
  2. When a consumer unmounts, its keys are withdrawn; other consumers’ keys are untouched.
  3. If two consumers set the same key to different values, a dev warning fires and the last write wins — a design smell, since one key should have one owner.
// Component A owns inputRef
cubit[APPLY_DEPS](ownerIdA, { inputRef });
// Component B owns onSubmit
cubit[APPLY_DEPS](ownerIdB, { onSubmit });
// bloc.deps === { inputRef, onSubmit } — assembled from both consumers

onDepsChanged — reacting when a handle arrives

Section titled “onDepsChanged — reacting when a handle arrives”

For handles that need to trigger initialization (a canvas, a rich-text-editor controller), implement onDepsChanged on the bloc. It fires after each deps merge, receives (next, prev), and lets the bloc diff which handle changed:

class CanvasRendererCubit extends Cubit<
RenderState,
{ sceneId: string },
{ canvas?: HTMLCanvasElement; controller?: RteController }
> {
onDepsChanged(next: this['deps'], prev: this['deps']) {
if (next.canvas && next.canvas !== prev.canvas) {
this.initRenderer(next.canvas); // canvas arrived or changed → init GPU loop
}
if (!next.canvas && prev.canvas) {
this.disposeRenderer(); // canvas unmounted → tear down
}
if (next.controller !== prev.controller) {
this.bindController(next.controller);
}
}
}

This is the canonical pattern for “wait for a ref, then initialize.” Blocs that don’t declare onDepsChanged just read this.deps.x lazily when an action runs.

An inline callback (onComplete={() => doThing()}) gets a new function identity every render. If you capture it once in deps, the bloc holds the first closure forever.

Prefer these patterns, best first:

  1. Callback inversion (recommended): expose state; let the component call its own fresh callback in a useEffect.

    const [{ uploadedId }] = useBloc(FileUploadCubit, { args: { endpoint } });
    useEffect(() => {
    if (uploadedId) onComplete(uploadedId); // always the current closure
    }, [uploadedId, onComplete]);
  2. Stabilize with useCallback before contributing it as a dep.

  3. Push via a bloc method from an effectuseEffect(() => cubit.setOnComplete(cb), [cb]).


For values that genuinely change over a shared instance’s life — a slides array, a selected theme — call an ordinary bloc method from a single effect. This is the XState/flutter_bloc model: no inputs slot, no new concept, just a method call after commit.

class CarouselCubit extends Cubit<CarouselState> {
slidesChanged(slides: Slide[]) {
this.patch({ slides, total: slides.length });
}
}
function Carousel({ slides }: { slides: Slide[] }) {
const [state, cubit] = useBloc(CarouselCubit, { args: { id } });
// ONE component owns syncing this live value
useEffect(() => {
cubit.slidesChanged(slides);
}, [cubit, slides]);
return /* ... */;
}

Convention: one component owns syncing any given live value. Two components both calling cubit.slidesChanged from their own effects on the same instance is a design smell (and rare — keyed args usually route such cases to distinct instances).


When useBloc resolves which instance to connect to, it consults sources in this order — the first that yields a key wins. This is the canonical ordering; useBloc restates the same list.

PrioritySourceResolved key
1own args on the useBloc callstatic key(args) if declared, else the structural hash of args
2<BlocProvider> context argssame resolution applied to inherited args, when the call passes no own args
3'default'singleton fallback — no args, no static key, no provider

args-derived identity is the only idiom for per-value instances. For an identity that can’t be derived from real data — anonymous, opaque, externally managed, or deliberately per-mount — synthesize one by adding a value to args (e.g. _id: useId()) and keying on it with static key.

Two questions decide everything: is this input identity (set once) or live (changes over the instance’s life)? and is the instance private to one component or shared?

Input defines identity / set onceInput is live and changing
Private to one componentuseBloc(C, { args: { ...data, _id: useId() } }) + static key on _id — own instance, seeded from argssynthetic per-mount args + call cubit.xChanged(v) from that component’s effect
Shared across consumersuseBloc(C, { args }) — args key the instance; override race impossiblecall cubit.xChanged(v) from the one owning component’s effect

Non-serializable handles (refs, callbacks, controllers) sit outside this matrix entirely — they go through the deps lane and never touch identity.


The select option (the per-consumer re-render selector) is unrelated to the deps lane on this page, despite both sounding “dependency”-ish. select opts a consumer out of auto-tracking; deps feeds non-serializable handles into the bloc. The names are deliberately distinct to keep the two concepts from colliding.

  • Best Practiceswhich lane to choose, and the judgment behind it
  • Mental Modelwhy instances are shared and ref-counted
  • useBloc — the complete option list and identity precedence (canonical)
  • Glossary — definitions of args, deps, static key
  • Patterns & Recipes — concrete copy-paste recipes
  • Cubitinit(args), onDepsChanged, and the mutation API