State bridges
When to use $state, createP5Bridge, or a reactive class.
State bridges and reactivity
The single hardest decision when wrapping p5 in a reactive framework is where state lives. p5's draw loop reads variables on every frame; Svelte wants its UI driven by reactive primitives. When the two have to meet, you have three options. Pick whichever is smallest for the job.
#The decision tree
| Situation | Use |
|---|---|
| Single value, used only inside one component | Plain $state variable |
| 3+ related fields, still scoped to one component | createP5Bridge({ ... }) |
| State shared across multiple sketches, components, or files | Reactive class in a .svelte.ts file |
There's no "always right" answer. The smallest tool that fits the situation is the right one. Reaching for a reactive class for one slider is overkill; a bare $state variable for a 12-field dashboard becomes painful as it grows.
#Option 1 - plain $state
When you have one value (or two) and a single component owns it, just close over a $state variable:
<script lang="ts">
import { P5Canvas } from 'svelte-p5';
import type p5 from 'p5';
let radius = $state(40);
const sketch = (p: p5) => {
p.setup = () => p.createCanvas(400, 400);
p.draw = () => {
p.background(240);
p.circle(p.mouseX, p.mouseY, radius);
};
};
</script>
<P5Canvas {sketch} />
<input type="range" bind:value={radius} min="10" max="200" />The sketch closure captures radius. Every p.draw reads its current value. No subscription, no effect, no proxy - Svelte's runes already give you live values.
When this is enough: single value, single component, you don't need to share it.
#Option 2 - createP5Bridge({ ... })
When you have several related fields you want to group, createP5Bridge returns an object with a state proxy. It's a thin wrapper around $state whose only job is to give you a single object you can pass around as one prop instead of threading every field individually:
<script lang="ts">
import { P5Canvas, createP5Bridge } from 'svelte-p5';
import type p5 from 'p5';
const bridge = createP5Bridge({
radius: 40,
hue: 200,
speed: 2
});
const sketch = (p: p5) => {
p.setup = () => p.createCanvas(400, 300);
p.draw = () => {
p.background(0, 0, 100);
p.fill(bridge.state.hue, 70, 90);
p.circle(p.mouseX, p.mouseY, bridge.state.radius);
};
};
</script>
<P5Canvas {sketch} />
<input type="range" bind:value={bridge.state.radius} />
<input type="range" bind:value={bridge.state.hue} min="0" max="360" />
<input type="range" bind:value={bridge.state.speed} min="0.5" max="6" step="0.1" />Internally, createP5Bridge is approximately:
export function createP5Bridge<T extends object>(initial: T) {
const state = $state<T>(initial);
return { state };
}That's it. The value-add isn't reactivity (which $state already provides) - it's grouping and a stable reference you can hand off:
<P5Canvas {sketch} />
<ControlPanel {bridge} />
<DebugReadout {bridge} />vs. threading three or seven props through every consumer.
When to reach for it: ≥3 related fields, all owned by one component, that you want to group as a single object.
#Option 3 - reactive class in a .svelte.ts file
When the state is shared - multiple sketches, multiple components, maybe an animation tick driving everything - put it in a class with $state fields and import it everywhere:
// sharedState.svelte.ts
class DashboardState {
tick = $state(0);
hue = $state(200);
density = $state(1);
paused = $state(false);
panels = $state({
orbit: true,
grid: true,
noise: true
});
#raf = 0;
constructor() {
if (typeof window !== 'undefined') {
const loop = () => {
if (!this.paused) this.tick += 1;
this.#raf = window.requestAnimationFrame(loop);
};
this.#raf = window.requestAnimationFrame(loop);
}
}
togglePanel(key: keyof DashboardState['panels']) {
this.panels[key] = !this.panels[key];
}
dispose() {
if (typeof window !== 'undefined') window.cancelAnimationFrame(this.#raf);
}
}
export const dashboard = new DashboardState();Anywhere you import dashboard, mutations propagate. A toolbar mutates dashboard.hue, three sketches read it on the next frame:
<script>
import { dashboard } from './sharedState.svelte.ts';
import { P5Canvas } from 'svelte-p5';
const sketch = (p) => {
p.draw = () => {
p.background(dashboard.hue, 30, 100);
// `tick` is the master clock - never use p.frameCount here
const t = dashboard.tick * 0.01;
// ...
};
};
</script>
<P5Canvas {sketch} />Three reasons this pattern beats per-component state for dashboards:
- Cross-sketch synchronization for free. All sketches read from the same
tick. Togglepausedand they all freeze together. No event bus. - The owner of the animation loop survives any individual sketch closing. If a panel unmounts, the others keep ticking from where they were.
- Methods and private fields.
togglePanel,dispose, the#rafhandle - these don't fit naturally on a$stateproxy.
The dashboard example (docs/examples/03-draggable-dashboard) is the reference for this pattern.
#Common confusion: do I need an effect to push state into p5?
No. p5's draw runs every frame and reads from your closure. Svelte's $state always returns the current value when you read it. So as long as the sketch closure captures a $state variable (or a bridge / class), every frame sees the latest value.
You only need $effect if something has to happen at the moment a value changes (e.g. force a redraw on a non-looping sketch):
<script>
let instance = $state<p5 | null>(null);
const bridge = createP5Bridge({ snapshot: 0 });
$effect(() => {
// Re-render when snapshot changes, even if the loop is stopped
bridge.state.snapshot;
instance?.redraw();
});
</script>
<P5Canvas {sketch} bind:instance />For looping sketches (p.draw running every frame), you don't need this.
#What about Svelte stores?
Svelte 5 runes replace writable() for almost every case. If you're starting fresh, use $state (or one of the patterns above). If you're migrating from Svelte 3/4 code with stores: replace $store with a reactive class field. The migration guide shows the pattern.
#Read-only state passing from p5 to Svelte
Bridges aren't one-way. The sketch can write to the same $state:
<script lang="ts">
import { createP5Bridge, P5Canvas } from 'svelte-p5';
const bridge = createP5Bridge({ fps: 0, mouseX: 0, mouseY: 0 });
const sketch = (p) => {
p.draw = () => {
bridge.state.fps = Math.round(p.frameRate());
bridge.state.mouseX = p.mouseX;
bridge.state.mouseY = p.mouseY;
// ...drawing
};
};
</script>
<P5Canvas {sketch} />
<p>fps: {bridge.state.fps} - at ({bridge.state.mouseX}, {bridge.state.mouseY})</p>Be aware that writing to $state every frame triggers a Svelte update every frame. For high-frequency telemetry, throttle inside the sketch (write every Nth frame) or use a bare let and only commit at the end of an interaction.