Reactive Primitives in React: A Custom State Management System
This challenge focuses on building fundamental reactive primitives within React, mimicking the core functionality of state management libraries like Zustand or Jotai. You'll create a simple, yet powerful, system for managing and deriving state, demonstrating a deeper understanding of React's reactivity and how to build upon it. This exercise is valuable for understanding the underlying mechanisms of state management and building custom solutions tailored to specific needs.
Problem Description
Your task is to implement three core reactive primitives: createSignal, createMemo, and createEffect. These primitives will form the foundation of a lightweight state management system.
-
createSignal(initialValue: T): { value: T; set: (newValue: T | ((prevValue: T) => T)) => void; }: This function creates a signal. A signal holds a single value of typeTand provides avaluegetter and asetfunction to update the value. Thesetfunction can accept either a new value directly or a function that receives the previous value and returns the new value. Changes to the signal's value should trigger re-renders in components that use it. -
createMemo(compute: (get: (signal: Signal<any>) => any) => T): T: This function creates a memoized value. It takes a computation functioncomputeas an argument. This function receives agetfunction, which allows access to the values of signals. Thecomputefunction should be executed only when its dependencies (signals accessed throughget) change. The result of the computation is cached and returned as the memoized value. -
createEffect(compute: (get: (signal: Signal<any>) => any) => void): void: This function creates an effect. It takes a computation functioncomputeas an argument, similar tocreateMemo. This function receives agetfunction to access signal values. Thecomputefunction should be executed whenever any of its dependencies (signals accessed throughget) change. Effects are typically used for side effects like logging, DOM mutations, or API calls.
Key Requirements:
- Reactivity: Changes to signals should trigger updates in memoized values and effects that depend on them.
- Dependency Tracking:
createMemoandcreateEffectmust correctly track dependencies on signals. - Efficient Updates:
createMemoshould only re-compute when its dependencies change. - TypeScript Safety: The code should be well-typed and leverage TypeScript's features to ensure type safety.
- No External Libraries: You should not use any external state management libraries (Redux, Zustand, Jotai, etc.). You are building your own primitives.
Expected Behavior:
- Signals should hold and update their values correctly.
- Memoized values should be computed only when their dependencies change.
- Effects should be executed whenever their dependencies change.
- Components using these primitives should re-render when the underlying state changes.
Examples
Example 1:
// Assume createSignal, createMemo, and createEffect are defined
const count = createSignal(0);
const doubleCount = createMemo(() => count.value * 2);
createEffect(() => {
console.log("Double count:", doubleCount);
});
count.set(5); // Output: Double count: 10
count.set(10); // Output: Double count: 20
Explanation: count is initialized to 0. doubleCount is memoized and depends on count. The effect logs doubleCount. When count is set to 5, doubleCount is recomputed to 10, and the effect is executed. When count is set to 10, doubleCount is recomputed to 20, and the effect is executed again.
Example 2:
// Assume createSignal, createMemo, and createEffect are defined
const name = createSignal("Alice");
const age = createSignal(30);
const greeting = createMemo(() => `Hello, ${name.value}! You are ${age.value} years old.`);
createEffect(() => {
console.log(greeting);
});
name.set("Bob"); // Output: Hello, Bob! You are 30 years old.
age.set(31); // Output: Hello, Bob! You are 31 years old.
Explanation: name and age are signals. greeting is a memoized value that depends on both name and age. The effect logs greeting. When name is set to "Bob", greeting is recomputed. When age is set to 31, greeting is recomputed again.
Example 3: (Edge Case - Nested Dependencies)
// Assume createSignal, createMemo, and createEffect are defined
const a = createSignal(1);
const b = createSignal(2);
const c = createMemo(() => a.value + b.value);
const d = createMemo(() => c.value * 2);
createEffect(() => {
console.log("d:", d);
});
a.set(3); // Output: d: 10
b.set(4); // Output: d: 14
Explanation: c depends on a and b. d depends on c. When a changes, c is recomputed, which in turn triggers the recomputation of d and the execution of the effect. When b changes, c is recomputed, which in turn triggers the recomputation of d and the execution of the effect.
Constraints
- Maximum Signal Count: The system should be able to handle at least 100 signals concurrently without significant performance degradation.
- Dependency Cycle Prevention: While not strictly required for this challenge, consider how you might prevent circular dependencies between signals, memoized values, and effects. (Hint: This is a complex topic, so a simple acknowledgement of the issue is sufficient).
- Performance: The re-computation of memoized values and execution of effects should be as efficient as possible, minimizing unnecessary work.
- TypeScript: Strict adherence to TypeScript types is required.
Notes
- Think about how to track dependencies between signals, memoized values, and effects. A simple approach might involve storing a list of signals that each memoized value and effect depends on.
- Consider using a
MaporSetto efficiently store and manage signals and their dependencies. - The
getfunction passed tocreateMemoandcreateEffectshould provide a way to access the current value of a signal. - This is a simplified implementation. Real-world state management libraries often include features like batching updates, devtools integration, and more sophisticated dependency tracking. Focus on the core reactive primitives for this challenge.
- The
createEffectfunction does not need to return anything. Its purpose is solely to execute side effects.