Building Custom Reactive Primitives in React
This challenge involves creating your own fundamental building blocks for reactive programming within a React application. You'll implement a simplified version of state management primitives that allow components to subscribe to changes and automatically re-render when the underlying data updates. This is a core concept in modern UI development and understanding it will deepen your grasp of React's rendering mechanism and state management patterns.
Problem Description
Your task is to implement two custom reactive primitives: createSignal and createEffect. These will mimic functionalities found in libraries like SolidJS or Preact Signals.
createSignal<T>(initialValue: T):
This function should return a tuple containing two elements:
- A getter function: When called, this function returns the current value of the signal.
- A setter function: This function accepts a new value of type
T(or a function(prev: T) => T) and updates the signal's value. Calling the setter should trigger any effects that are currently listening to this signal.
createEffect(callback: () => void):
This function should execute the provided callback function immediately and then re-execute it whenever any signal that was read within the callback during its last execution has its value changed.
Key Requirements:
- Reactivity: Changes to a signal must automatically trigger re-execution of effects that depend on it.
- Dependency Tracking:
createEffectneeds to track which signals it reads so it knows when to re-run. - No React Hooks Dependence (within primitives): Your
createSignalandcreateEffectimplementations should not directly use React Hooks likeuseState,useEffect, oruseReffor their core reactive logic. The integration with React will be demonstrated in the examples by using these primitives within a React component. - TypeScript: All implementations and type definitions must be in TypeScript.
Expected Behavior:
When a signal's value is updated, any active effect that read that signal during its last execution should be scheduled to run again. The scheduler should be simple, meaning effects can run immediately or be batched if necessary (for this challenge, immediate execution is fine).
Edge Cases:
- A signal can be updated multiple times before an effect runs. The effect should run with the latest value.
- An effect might not read any signals. It should still run once initially.
- An effect might read signals that are not yet defined (though this is less likely in a typical scenario, consider the implications).
Examples
Example 1: Basic Signal and Effect
// Assume createSignal and createEffect are implemented as described.
const [count, setCount] = createSignal(0);
createEffect(() => {
console.log("Count is:", count());
});
setCount(1); // Logs "Count is: 1"
setCount(2); // Logs "Count is: 2"
Explanation:
Initially, createEffect runs and logs "Count is: 0". When setCount(1) is called, the effect re-runs because it depends on count(), and logs "Count is: 1". The same happens for setCount(2).
Example 2: Using Setter Function with previous value
// Assume createSignal and createEffect are implemented as described.
const [count, setCount] = createSignal(0);
createEffect(() => {
console.log("Count is:", count());
});
setCount(prev => prev + 1); // Logs "Count is: 1"
setCount(prev => prev * 2); // Logs "Count is: 2"
Explanation:
The effect first logs "Count is: 0". Then setCount(prev => prev + 1) updates count to 1, triggering the effect to log "Count is: 1". Finally, setCount(prev => prev * 2) updates count to 2, triggering the effect to log "Count is: 2".
Example 3: Multiple Signals and Effects
// Assume createSignal and createEffect are implemented as described.
const [firstName, setFirstName] = createSignal("John");
const [lastName, setLastName] = createSignal("Doe");
const fullName = () => `${firstName()} ${lastName()}`; // A derived value, not a signal itself for this problem
createEffect(() => {
console.log("Full name:", fullName());
});
setFirstName("Jane"); // Logs "Full name: Jane Doe"
setLastName("Smith"); // Logs "Full name: Jane Smith"
Explanation:
The effect initially logs "Full name: John Doe". When setFirstName changes, the effect re-runs and logs "Full name: Jane Doe". When setLastName changes, the effect re-runs again and logs "Full name: Jane Smith".
Example 4: Effect with no initial dependency, then dependency added
// Assume createSignal and createEffect are implemented as described.
const [value, setValue] = createSignal("initial");
const effectRef = { current: null as (() => void) | null };
effectRef.current = () => {
console.log("Current value:", value());
};
createEffect(effectRef.current); // Logs "Current value: initial"
// If the effect were to re-run based on some other logic,
// and then subsequently read 'value', it would track it.
// For this specific challenge, the effect runs once initially
// and then re-runs when dependencies change. The above examples
// cover this adequately.
Constraints
- Type Safety: Your implementation must be strictly typed using TypeScript.
- Performance: While not a hard constraint for this challenge, aim for an efficient dependency tracking and update mechanism. Avoid unnecessary re-renders or computations.
- No built-in React Hooks for Reactive Logic:
createSignalandcreateEffectthemselves must not rely on React's internal hooks for their core functionality. The integration with React components is for demonstration.
Notes
- You'll need a mechanism to store the dependency graph: which effects depend on which signals.
- When an effect runs, you need to clear its old dependencies and re-establish new ones based on the signals it reads during that execution.
- Consider how you'll manage the execution order and potential batching of effect updates, although for this challenge, immediate synchronous execution is acceptable for simplicity.
- Think about how to implement the "listening" or "subscribing" aspect. A common pattern involves storing callbacks or subscribers within the signal itself.
- The
createEffectfunction should take acallbackthat returnsvoid. It doesn't return anything. - The
createSignalfunction returns a tuple[getter, setter].