Atoms
Reactive state primitives for dynamic visualizations
What are Atoms?
Atoms are reactive state containers, based on Jotai's implementation. They hold values that, when changed, automatically trigger updates to any items that depend on them.
The key insight is that any property of any scene item can be an atom. When that atom changes, Uzay automatically:
- Marks the affected items as dirty
- Schedules a re-render
- Updates only what changed
This makes it trivial to create dynamic, interactive visualizations.
To learn more about atoms, check out Jotai. The atom implementation in Uzay is a simple wrapper around it, which binds atoms to the scene's store.
Creating Atoms
Use scene.atom() to create atoms. There are three types:
Primitive Atoms
Primitive atoms hold a value directly and can be updated with .set():
const scene = new Scene3D();
// Create a primitive atom with initial value 0
const x = scene.atom(0);
// Read the value
console.log(x.get()); // 0
// Update the value
x.set(5);
console.log(x.get()); // 5Derived Atoms
Derived atoms compute their value from other atoms. They update automatically when their dependencies change:
const x = scene.atom(1);
const y = scene.atom(2);
// Derived atom that depends on x and y
const sum = scene.atom((get) => get(x) + get(y));
console.log(sum.get()); // 3
x.set(10);
console.log(sum.get()); // 12 (automatically updated)
sum.set(67); // ERROR: Derived atoms don't have a set methodThe get function tells the atom which other atoms to subscribe to. Whenever any dependency changes, the derived atom recomputes.
Derived atoms can't be written to, since their value is computed based on the value of other atoms.
Writable Atoms
Writable atoms are created similar to derived atoms, but with a second argument that is a write function:
const x = scene.atom(1);
// When x changes, y will be updated to x * 2
// But we can also update y, and x will be updated to y / 2
const y = scene.atom(
(get) => get(x) * 2,
(get, set, value: number) => {
set(x, value / 2);
}
);This is useful for creating two-way bindings between atoms.
Bound Atoms
Unlike regular Jotai atoms, Uzay atoms are "bound" to the scene's store. This means you can call .get(), .set(), and .sub() directly on them without passing a store:
// Jotai (requires a separate store)
const store = createStore();
const myAtom = atom(42);
const value = store.get(myAtom);
store.set(myAtom, newValue);
// Uzay (bound to scene's store)
const myAtom = scene.atom(42);
const value = myAtom.get();
myAtom.set(newValue);This makes the API much cleaner when working with many atoms.
Using Atoms with Items
Any property of a scene item can be an atom. This is where reactivity happens:
const radius = scene.atom(2);
const point = scene.create("point3d", {
coords: vec3(0, 0, 0),
color: "red",
radius: radius, // Pass the atom, not the value
});
// The point automatically updates when radius changes
radius.set(5);
// We can also update it this way, since they refer to the same atom
point.radius.set(5);You can also create derived atoms inline:
const t = scene.atom(0);
const point = scene.create("point3d", {
coords: scene.atom((get) => {
const time = get(t);
return vec3(Math.cos(time), Math.sin(time), 0);
}),
color: "gold",
});
// The point moves in a circle as t changes
t.set(Math.PI / 2);Subscribing to Changes
Use .sub() to run a callback whenever an atom changes:
const value = scene.atom(0);
const unsubscribe = value.sub(() => {
console.log("Value changed to:", value.get());
});
value.set(1); // Logs: "Value changed to: 1"
value.set(2); // Logs: "Value changed to: 2"
// Stop listening
unsubscribe();Connecting to the DOM
Atoms make it easy to connect UI elements to your visualization:
const slider = document.querySelector("#my-slider") as HTMLInputElement;
const t = scene.atom(0);
// Update atom when slider changes
slider.addEventListener("input", (e) => {
const value = parseFloat((e.target as HTMLInputElement).value);
t.set(value);
});
// Update slider when atom changes (two-way binding)
t.sub(() => {
slider.value = t.get().toString();
});Derived Atoms with Multiple Dependencies
Derived atoms can depend on any number of other atoms:
const a = scene.atom(1);
const b = scene.atom(2);
const c = scene.atom(3);
const combined = scene.atom((get) => {
return vec3(get(a), get(b), get(c));
});
// combined updates when any of a, b, or c change
a.set(10);
console.log(combined.get()); // { x: 10, y: 2, z: 3 }Atoms with Functions
Some item properties expect functions, like f in parametric curves or surfaces. When a function's behavior depends on parameters, the best approach is to make those parameters atoms and derive the function from them:
const amplitude = scene.atom(1);
const frequency = scene.atom(1);
const f = scene.atom((get) => {
const a = get(amplitude);
const freq = get(frequency);
return (x: number, z: number) => a * Math.sin(freq * Math.sqrt(x * x + z * z));
});
const surface = scene.create("surface3d", { f });
// The surface updates automatically when either parameter changes
amplitude.set(2);
frequency.set(0.5);This way the function always stays in sync with its dependencies, and you control the parameters individually.
Read dependencies outside the returned function
When deriving a function atom, read all dependencies outside the function you return:
const myAtom = scene.atom(0);
// BAD: get() is called inside the returned function, not tracked as a dependency
const f = scene.atom((get) => {
return (t: number) => vec3(get(myAtom), Math.sin(t), Math.cos(t));
});
// GOOD: get() is called in the derivation, value is captured by the closure
const f = scene.atom((get) => {
const x = get(myAtom);
return (t: number) => vec3(x, Math.sin(t), Math.cos(t));
});Primitive Function Atoms
Since passing a function to scene.atom() always creates a derived atom (the function is treated as the computation, not the value), you need to use { mode: "value" } if you want to store a function as a plain value:
const f = scene.atom((x: number, z: number) => x + z, { mode: "value" });
f.set((x, z) => Math.sin(x) * Math.cos(z)); // worksThis is rarely needed. In most cases, if a function needs to change, it's because its parameters are changing, and a derived atom handles that more cleanly.
Automatic Atomization
If you pass a plain value where an atom is expected, Uzay automatically wraps it in a primitive atom.
These methods are all equivalent:
const point1 = scene.create("point3d", { radius: 2 });
point1.radius.set(5);
const point2 = scene.create("point3d", { radius: scene.atom(2) });
point2.radius.set(5);
const myAtom = scene.atom(2);
const point3 = scene.create("point3d", { radius: myAtom });
myAtom.set(5);You can still access the atom with point1.radius.
It's just that if you're going to use the atom in multiple places (like dependencies for other atoms), defining it separately might be more convenient.
API Reference
Creating Atoms
| Method | Signature | Description |
|---|---|---|
scene.atom(value) | <T>(value: T) => BoundAtom<PrimitiveAtom<T>> | Create a primitive atom |
scene.atom(readFn) | <T>((get) => T) => BoundAtom<Atom<T>> | Create a derived atom |
scene.atom(readFn, writeFn) | <T>((get) => T, (set) => void) => BoundAtom<WritableAtom<T>> | Create a writable atom |
scene.atom(fn, { mode: "value" }) | <T>(fn: T) => BoundAtom<PrimitiveAtom<T>> | Create a primitive atom that stores a function as its value |
Atom Methods
| Method | Signature | Description |
|---|---|---|
.get() | () => T | Read the current value |
.set(value) | (value: T) => void | Update a primitive atom's value |
.sub(callback) | (fn: () => void) => () => void | Subscribe to changes; returns unsubscribe function |
Derived atoms are read-only. Calling .set() on a derived atom will throw an error.