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.
The three input lanes
Section titled “The three input lanes”| Lane | Purpose | Keys identity? | Lifetime | Example |
|---|---|---|---|---|
args | Typed creation/config data | Yes (structural hash or static key) | Set once via init(args) | userId, endpoint, filters |
deps | Non-serializable handles | Never | Live, per-consumer merged | inputRef, stable callback, emblaApi |
| events | Values that change over the instance’s life | N/A | Called from effects | cubit.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
Argsisvoid.
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-checkedconst [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.
Identity keying
Section titled “Identity keying”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.
Per-component private instances
Section titled “Per-component private instances”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 argsclass 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 must be serializable
Section titled “Args must be serializable”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.
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.
"alpha")renders1reads: state.count — instance keyed by args.key
deps: non-serializable handles
Section titled “deps: non-serializable handles”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.xat action time, may beundefined. 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 }}Wiring deps from a component
Section titled “Wiring deps from a component”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.
Multi-consumer merge
Section titled “Multi-consumer merge”Different components can contribute different slices of deps to the same instance. The rules are simple:
- Each consumer’s keys are shallow-merged into
bloc.deps. - When a consumer unmounts, its keys are withdrawn; other consumers’ keys are untouched.
- 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 inputRefcubit[APPLY_DEPS](ownerIdA, { inputRef });
// Component B owns onSubmitcubit[APPLY_DEPS](ownerIdB, { onSubmit });
// bloc.deps === { inputRef, onSubmit } — assembled from both consumersonDepsChanged — 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.
The callback staleness gotcha
Section titled “The callback staleness gotcha”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:
-
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]); -
Stabilize with
useCallbackbefore contributing it as a dep. -
Push via a bloc method from an effect —
useEffect(() => cubit.setOnComplete(cb), [cb]).
Events: live data from one owning effect
Section titled “Events: live data from one owning effect”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).
Identity model and precedence
Section titled “Identity model and precedence”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.
| Priority | Source | Resolved key |
|---|---|---|
| 1 | own args on the useBloc call | static key(args) if declared, else the structural hash of args |
| 2 | <BlocProvider> context args | same 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.
Decision matrix
Section titled “Decision matrix”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 once | Input is live and changing | |
|---|---|---|
| Private to one component | useBloc(C, { args: { ...data, _id: useId() } }) + static key on _id — own instance, seeded from args | synthetic per-mount args + call cubit.xChanged(v) from that component’s effect |
| Shared across consumers | useBloc(C, { args }) — args key the instance; override race impossible | call 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.
A note on naming
Section titled “A note on naming”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.
See also
Section titled “See also”- Best Practices — which lane to choose, and the judgment behind it
- Mental Model — why 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
- Cubit —
init(args),onDepsChanged, and the mutation API