Building a Minimal Reactive Core for React
This challenge focuses on understanding the fundamental principles of reactivity and how they can be implemented within a React-like environment. You will build a simplified version of a reactive primitive that allows components to automatically re-render when their underlying data changes. This is crucial for building dynamic and responsive user interfaces.
Problem Description
Your task is to create a minimal reactive core for a React-like framework. This core will consist of two main parts:
createSignal: A function that creates a "signal." A signal is a reactive value that can be read and written to. When a signal's value is updated, any part of the application that is "listening" to this signal should be notified and potentially re-render.createEffect: A function that registers a side effect that should run whenever a signal it depends on changes. In the context of React, this is akin to auseEffecthook that re-runs when its dependencies change.
Key Requirements:
createSignal<T>(initialValue: T): { get: () => T; set: (newValue: T) => void; }:- Takes an initial value of type
T. - Returns an object with two methods:
get(): Returns the current value of the signal.set(newValue: T): Updates the signal's value.
- Reading a signal's value within an effect or component should implicitly register that effect/component as a listener to that signal.
- Writing to a signal should trigger all registered listeners.
- Takes an initial value of type
createEffect(callback: () => void):- Takes a callback function.
- The callback function should be executed immediately upon registration.
- During the execution of the callback, any
createSignal'sget()methods that are called should be tracked. - Whenever any of the tracked signals change, the callback should be re-executed.
- The effect should handle dependencies correctly, meaning if a signal is no longer read within an effect, it should stop being a dependency.
Expected Behavior:
When a signal's value is updated, any effects that have previously read that signal should re-run. This mimics how React components re-render when their state (which can be thought of as signals) changes.
Edge Cases to Consider:
- Dependencies changing: What happens if an effect stops reading a signal it previously read? It should no longer re-run when that specific signal changes.
- Chained updates: If updating one signal causes another signal to update within an effect, the system should handle this without infinite loops (e.g., by batching or using a stable update mechanism). For this challenge, we'll assume a simpler execution order where updates are processed synchronously.
- No dependencies: An effect that doesn't read any signals should run only once.
Examples
Example 1: Basic Signal and Effect
// Simulate a simple rendering mechanism (not part of the challenge, for illustration)
let renderCount = 0;
const simulateRender = (message: string) => {
console.log(`Component rendered: ${message} (Render #${++renderCount})`);
};
// --- Challenge Implementation ---
const { createSignal, createEffect } = (() => {
let activeEffect: (() => void) | null = null;
const signalMap = new Map<() => any, Set<() => void>>(); // Map signal getter to set of effects that depend on it
const createSignal = <T>(initialValue: T) => {
let value = initialValue;
const getter = () => {
if (activeEffect) {
// Register activeEffect as a listener for this signal
if (!signalMap.has(getter)) {
signalMap.set(getter, new Set());
}
signalMap.get(getter)!.add(activeEffect);
}
return value;
};
const setter = (newValue: T) => {
if (value !== newValue) {
value = newValue;
// Notify all dependent effects
const effectsToRun = signalMap.get(getter);
if (effectsToRun) {
// In a real framework, this might be batched. For this challenge, synchronous is fine.
effectsToRun.forEach(effect => effect());
}
}
};
return { get: getter, set: setter };
};
const createEffect = (callback: () => void) => {
activeEffect = () => {
// Clear previous dependencies before re-running
// This is a simplified way to handle dependency cleanup.
// In a more robust system, you'd explicitly track dependencies.
signalMap.forEach(effects => effects.delete(callback));
activeEffect = callback; // Set active effect to the current callback for dependency tracking
callback();
activeEffect = null; // Reset after execution
};
activeEffect(); // Run the effect immediately
activeEffect = null; // Reset active effect after initial run
};
return { createSignal, createEffect };
})();
// --- End Challenge Implementation ---
// Usage:
const count = createSignal(0);
createEffect(() => {
simulateRender(`Current count is: ${count.get()}`);
});
console.log("Initial state:");
count.set(5);
console.log("After setting count to 5:");
count.set(10);
console.log("After setting count to 10:");
Expected Output:
Component rendered: Current count is: 0 (Render #1)
Initial state:
Component rendered: Current count is: 5 (Render #2)
After setting count to 5:
Component rendered: Current count is: 10 (Render #3)
After setting count to 10:
Explanation:
createSignal(0)creates a signalcountinitialized to0.- The first
createEffectis registered. Its callback runs immediately, callingcount.get(). This registers the effect as a listener tocount. It renders "Current count is: 0". count.set(5)updates the signal. This triggers the registered effect, causing it to re-run. It renders "Current count is: 5".count.set(10)updates the signal again, triggering the effect to re-run, rendering "Current count is: 10".
Example 2: Effect with Multiple Signals and Changing Dependencies
// Simulate a simple rendering mechanism
let renderCount = 0;
const simulateRender = (message: string) => {
console.log(`Component rendered: ${message} (Render #${++renderCount})`);
};
// --- Challenge Implementation (reuse from Example 1) ---
const { createSignal, createEffect } = (() => {
let activeEffect: (() => void) | null = null;
const signalMap = new Map<() => any, Set<() => void>>(); // Map signal getter to set of effects that depend on it
const createSignal = <T>(initialValue: T) => {
let value = initialValue;
const getter = () => {
if (activeEffect) {
if (!signalMap.has(getter)) {
signalMap.set(getter, new Set());
}
signalMap.get(getter)!.add(activeEffect);
}
return value;
};
const setter = (newValue: T) => {
if (value !== newValue) {
value = newValue;
const effectsToRun = signalMap.get(getter);
if (effectsToRun) {
effectsToRun.forEach(effect => effect());
}
}
};
return { get: getter, set: setter };
};
const createEffect = (callback: () => void) => {
let currentDependencies = new Set<() => any>(); // Track current dependencies for this effect
const effectRunner = () => {
// Clear previously registered dependencies for this effect
signalMap.forEach((effects, signalGetter) => {
if (effects.has(effectRunner)) {
effects.delete(effectRunner);
}
});
currentDependencies.clear();
activeEffect = effectRunner; // Set as active for dependency tracking
callback();
activeEffect = null; // Reset after execution
};
// Initial run
effectRunner();
};
return { createSignal, createEffect };
})();
// --- End Challenge Implementation ---
// Usage:
const firstName = createSignal("Alice");
const lastName = createSignal("Smith");
const showGreeting = createSignal(true);
let effectRunCount = 0;
createEffect(() => {
effectRunCount++;
let message = "";
if (showGreeting.get()) {
message = `Hello, ${firstName.get()} ${lastName.get()}!`;
} else {
message = `Welcome!`;
}
simulateRender(message);
});
console.log("--- Initial Render ---");
console.log("Setting firstName...");
firstName.set("Bob"); // Should re-render
console.log("Setting lastName...");
lastName.set("Johnson"); // Should re-render
console.log("Setting showGreeting to false...");
showGreeting.set(false); // Should re-render with different message
console.log("Setting firstName again (shouldn't re-render when showGreeting is false)...");
firstName.set("Charlie"); // Should NOT re-render because firstName is not read when showGreeting is false
console.log("Setting showGreeting to true again...");
showGreeting.set(true); // Should re-render
Expected Output:
--- Initial Render ---
Component rendered: Hello, Alice Smith! (Render #1)
Setting firstName...
Component rendered: Hello, Bob Smith! (Render #2)
Setting lastName...
Component rendered: Hello, Bob Johnson! (Render #3)
Setting showGreeting to false...
Component rendered: Welcome! (Render #4)
Setting firstName again (shouldn't re-render when showGreeting is false)...
Setting showGreeting to true again...
Component rendered: Hello, Charlie Johnson! (Render #5)
Explanation:
- The effect runs initially, reading
showGreeting,firstName, andlastName. It renders "Hello, Alice Smith!". firstName.set("Bob")triggers the effect. It re-reads all signals and renders "Hello, Bob Smith!".lastName.set("Johnson")triggers the effect again. It renders "Hello, Bob Johnson!".showGreeting.set(false)triggers the effect. Now, onlyshowGreetingis read. It renders "Welcome!".firstName.set("Charlie")is called. SincefirstNameis NOT read whenshowGreetingisfalse, the effect does not re-run.showGreeting.set(true)triggers the effect. It re-reads all signals and renders "Hello, Charlie Johnson!".
Constraints
- The implementation of
createSignalandcreateEffectshould be contained within a single closure or module. - The solution should use TypeScript with type annotations.
- No external libraries are allowed.
- The solution should be efficient enough to handle hundreds of signals and effects without significant performance degradation.
- The maximum depth of nested effects is not a primary concern for this challenge, but avoid stack overflows in simple cases.
Notes
- This challenge is about understanding the core reactive primitives. You are building the engine, not the car.
- Think about how to track which signals an effect depends on. A common pattern is to have a global "currently running effect" variable that signals can check.
- When an effect re-runs, you need to ensure its old dependencies are cleaned up before new ones are registered to avoid stale subscriptions.
- Consider how to distinguish between a signal's value being the same and the signal itself being updated. The
setoperation should only trigger updates if the value has genuinely changed. - The
simulateRenderfunction is for demonstration purposes and is not part of the challenge's implementation. You will implementcreateSignalandcreateEffectwithin a self-contained scope.