Reactivity
Kaori's reactivity system is built on signals from @preact/signals-core. This provides automatic updates without manual update calls.
Signals
Signals are the foundation of reactivity. They're containers for values that notify subscribers when they change.
Creating Signals
import { signal } from 'kaori.js';
const count = signal(0);
const name = signal('Kaori');
const user = signal({ name: 'John', age: 25 });Reading Signals
Access the current value with .value:
const count = signal(10);
console.log(count.value); // 10Writing Signals
Update the value by assigning to .value:
const count = signal(0);
count.value = 5; // Set new value
count.value++; // Increment
count.value += 10; // AddPeek Without Subscribing
Use .peek() to read without creating a dependency:
const count = signal(0);
effect(() => {
// This will NOT re-run when count changes
console.log(count.peek());
});Computed Values
Computed values derive from other reactive sources and update automatically.
Creating Computed
import { signal, computed } from 'kaori.js';
const firstName = signal('John');
const lastName = signal('Doe');
const fullName = computed(() => `${firstName.value} ${lastName.value}`);
console.log(fullName.value); // "John Doe"
firstName.value = 'Jane';
console.log(fullName.value); // "Jane Doe"Computed are Read-Only
You cannot assign to computed values:
const double = computed(() => count.value * 2);
// double.value = 10; // ❌ Error!Computed are Lazy
Computed values only recalculate when accessed:
const expensive = computed(() => {
console.log('Computing...');
return heavyCalculation();
});
// Nothing logged yet
expensive.value; // "Computing..." logged
expensive.value; // Nothing logged (cached)Effects
Effects run side effects in response to reactive changes.
Basic Effects
import { signal, effect } from 'kaori.js';
const count = signal(0);
effect(() => {
console.log('Count is:', count.value);
});
// Logs: "Count is: 0"
count.value = 5;
// Logs: "Count is: 5"Effect Cleanup
Effects can return a cleanup function:
effect(() => {
const timer = setTimeout(() => {
console.log('Timer!', count.value);
}, 1000);
return () => clearTimeout(timer);
});Component Effects
In components, use the effect export from kaori.js:
import { signal, effect } from 'kaori.js';
function Component() {
const count = signal(0);
effect(() => {
document.title = `Count: ${count.value}`;
});
return () => <button onClick={() => count.value++}>+</button>;
}Batching Updates
Batch multiple updates to prevent unnecessary re-renders:
import { signal, batch } from 'kaori.js';
const firstName = signal('John');
const lastName = signal('Doe');
// Without batching - triggers 2 updates
firstName.value = 'Jane';
lastName.value = 'Smith';
// With batching - triggers 1 update
batch(() => {
firstName.value = 'Jane';
lastName.value = 'Smith';
});Untracked Reads
Read signals without creating dependencies:
import { signal, computed, untracked } from 'kaori.js';
const a = signal(1);
const b = signal(2);
const sum = computed(() => {
// Changes to 'b' won't trigger re-computation
return a.value + untracked(() => b.value);
});Reactivity in Components
Automatic Reactivity
When you access signals in the render function, Kaori automatically tracks them: This is because the render function is wrapped in a reactive contect (effect)
When you access signals inside the render function, Kaori automatically tracks them. This happens because the render function runs within a reactive context (effect).
That’s why destructuring props outside the render function breaks reactivity — it disconnects the signal from tracking. But you can safely destructure inside the render function, since it re-runs whenever dependencies change.
function Counter() {
const count = signal(0);
// ✅ Automatically reactive
return () => <div>{count.value}</div>;
}Setup vs Render Phase
Components have two phases:
function Component(props) {
// SETUP PHASE - Runs once
const state = signal(0);
const derived = computed(() => state.value * 2);
// Effects and lifecycle hooks here
effect(() => {
console.log('State:', state.value);
});
// RENDER PHASE - Runs on updates
return () => (
<div>
{state.value} × 2 = {derived.value}
</div>
);
}Why Return a Function?
Returning a function tells Kaori "this component is reactive":
// ✅ Reactive - re-renders when count changes
function Reactive() {
const count = signal(0);
return () => <div>{count.value}</div>;
}
// ❌ Not reactive - renders once
function Static() {
return <div>Static content</div>;
}Manual Updates
Sometimes you need to trigger updates without signals:
import { getHandle } from 'kaori.js';
function Manual() {
const handle = getHandle();
let count = 0; // Not a signal!
function increment() {
count++;
handle.update(); // Manually trigger render
}
return () => <button onClick={increment}>Count: {count}</button>;
}WARNING
Use signals instead of manual updates whenever possible. Manual updates are an escape hatch for special cases.
Reactivity Best Practices
✅ Do
// Read signals in render
function Good() {
const count = signal(0);
return () => <div>{count.value}</div>;
}
// Use computed for derived state
const doubled = computed(() => count.value * 2);
// Use effects for side effects
effect(() => {
localStorage.setItem('count', count.value.toString());
});❌ Don't
// Don't read signals in setup without effects
function Bad() {
const count = signal(0);
const doubled = count.value * 2; // ❌ Not reactive!
return () => <div>{doubled}</div>;
}
// Don't create signals in render
function Bad2() {
return () => {
const count = signal(0); // ❌ New signal every render!
return <div>{count.value}</div>;
};
}
// Don't use signals for local UI state that doesn't need to persist
function Bad3() {
const hover = signal(false); // ❌ Use local state instead
return () => <div onMouseEnter={() => (hover.value = true)}>...</div>;
}Advanced Patterns
Conditional Dependencies
const enabled = signal(true);
const value = signal(0);
const result = computed(() => {
if (!enabled.value) {
return null;
}
return value.value * 2; // Only depends on 'value' when enabled
});Derived Signals
function createDerivedSignal<T>(source: Signal<T>, transform: (value: T) => T) {
const derived = signal(transform(source.value));
effect(() => {
derived.value = transform(source.value);
});
return derived;
}
const count = signal(5);
const doubled = createDerivedSignal(count, x => x * 2);Signal as Event Emitter
const events = signal<{ type: string; data: any } | null>(null);
// Emit event
events.value = { type: 'click', data: { x: 10, y: 20 } };
// Listen to events
effect(() => {
const event = events.value;
if (event?.type === 'click') {
console.log('Clicked at:', event.data);
}
});Performance Tips
Use computed for expensive calculations
tsxconst expensive = computed(() => heavyCalculation(data.value));Batch related updates
tsxbatch(() => { signal1.value = a; signal2.value = b; });Use peek() to avoid dependencies
tsxconst current = count.peek(); // Won't trackMemoize static data
tsxconst config = { /* large object */ }; const data = signal(config); // Share reference
Next Steps
- Learn about Components patterns
- Explore Lifecycle hooks
- Check out API Reference for complete details