Build Your Own React-Inspired Custom Hooks System
Imagine building a library of reusable logic that can be shared across different components without resorting to complex inheritance patterns. This challenge asks you to create a simplified, custom hook system in JavaScript, inspired by React's custom hooks. This system will allow developers to encapsulate stateful logic and side effects, making it easy to reuse and compose across your application.
Problem Description
Your task is to implement a basic framework for creating and using custom hooks in JavaScript. This system should mimic the core concepts of React hooks:
- Hook Registration: A mechanism to register custom hooks.
- Hook Execution Context: A way to manage the state and effects associated with each hook instance within a "component" (which will be simulated).
- State Management: A
useStateequivalent that allows hooks to manage their own local state. - Effect Management: A
useEffectequivalent that allows hooks to run side effects. - Hook Chaining: The ability for custom hooks to call other custom hooks.
You will need to create a createHookSystem function that returns an object with methods for defining and running hooks. The system should support a simulated "component" context to track hook state and execution.
Key Requirements:
createHookSystem(): This function should return an object containingdefineHookandrunHook(or similar) methods.defineHook(name, hookFunction):- Registers a named hook.
hookFunctionwill be the JavaScript function defining the hook's logic.hookFunctionshould have access to the system's internal state management functions (likeuseState,useEffect).
runHook(hookName, ...args):- Executes a registered hook.
- If the hook has already been run in the current "component" context, it should resume its state.
- If it's the first time running, it should initialize its state.
- Should return the value(s) returned by the
hookFunction.
useState(initialValue)(withinhookFunction):- Provides a way for hooks to declare state.
- Returns an array
[state, setState]. setStateshould trigger a re-execution of the relevant hook's logic within the component context.
useEffect(callback, dependencies)(withinhookFunction):- Provides a way for hooks to declare side effects.
callbackis the function to execute.dependenciesis an array of values. Thecallbackshould only re-run if any of these dependencies have changed since the last execution.- The
callbackcan optionally return a cleanup function, which should be executed before the next effect runs or when the component unmounts (simulated).
- Simulated Component Context: You'll need a way to manage which hooks are associated with a particular "component" instance and in what order they were called. This is crucial for correctly re-initializing state on subsequent runs.
Expected Behavior:
- When
runHookis called for the first time for a specific hook within a simulated component, itshookFunctionis executed, and its initial state and effects are set up. - When
runHookis called again for the same hook within the same simulated component context, thehookFunctionis executed again, but it should receive its previously stored state.useStatecalls within the hook should return the current state and a function to update it.useEffectdependencies should be checked for changes. - The order of
runHookcalls matters for state management. Hooks should be called in the same order on every render of a simulated component. useEffectcleanup functions should be invoked correctly.
Edge Cases:
- Calling
runHookfor a hook that hasn't been defined. - Conditional calling of hooks (e.g.,
if (condition) { myHook(); }) will break the system because the order of hook calls must be consistent. You should not need to handle this explicitly in the implementation, but users of your system should be aware. - Multiple calls to
setStatewithin a single execution of a hook. useEffectwith no dependencies (always runs).useEffectwith an empty dependency array (runs only once).
Examples
Let's simulate a component's lifecycle with a simple runComponent function that orchestrates hook calls.
Example 1: Basic useState
// Assume createHookSystem is available and has been called.
const { defineHook, runHook, runComponent } = createHookSystem();
// Define a simple counter hook
defineHook('useCounter', (initialValue = 0) => {
const [count, setCount] = runHook('useState', initialValue); // Using a built-in hook
return { count, increment: () => setCount(count + 1) };
});
// Simulate a component's "render" cycle
const componentState = {}; // Stores state for the simulated component
function runComponent() {
// Reset hook execution context for this "render"
const hookRunner = createHookRunner(componentState);
const runHookInContext = hookRunner.runHook;
// Run our custom hook
const { count, increment } = runHookInContext('useCounter', 0);
console.log(`Current count: ${count}`);
return { count, increment };
}
// --- Simulation ---
console.log("--- First render ---");
let renderedState = runComponent(); // Current count: 0
console.log("--- Second render (after simulated increment) ---");
renderedState.increment(); // This call would trigger a re-run of useCounter's logic in a real system.
// In this simulation, we manually simulate the re-run by calling runComponent again.
renderedState = runComponent(); // Current count: 1
console.log("--- Third render ---");
renderedState = runComponent(); // Current count: 1
Output:
--- First render ---
Current count: 0
--- Second render (after simulated increment) ---
Current count: 1
--- Third render ---
Current count: 1
Explanation:
The useCounter hook uses useState to manage its count. The runComponent function simulates the rendering of a component. The first call to runComponent initializes useCounter with initialValue = 0. The second call, after increment is called (which internally calls setCount), re-executes useCounter with the updated state.
Example 2: useEffect with Dependencies
// Assume createHookSystem is available and has been called.
const { defineHook, runHook, runComponent } = createHookSystem();
let logCalls = []; // To track effect executions
defineHook('useLogger', (message, dependencies) => {
runHook('useEffect', () => {
logCalls.push(`Effect ran for: ${message}`);
return () => {
logCalls.push(`Cleanup for: ${message}`);
};
}, dependencies);
return () => { /* Hook returns nothing for simplicity */ };
});
const componentState = {};
function runComponent(userId) {
const hookRunner = createHookRunner(componentState);
const runHookInContext = hookRunner.runHook;
runHookInContext('useLogger', `User ${userId} logged in`, [userId]);
console.log(`Component rendered with userId: ${userId}`);
}
console.log("--- First render (userId: 1) ---");
runComponent(1); // Effect ran for: User 1 logged in
console.log("--- Second render (userId: 1, no change) ---");
runComponent(1); // No effect should run
console.log("--- Third render (userId: 2, dependency change) ---");
runComponent(2); // Cleanup for: User 1 logged in, Effect ran for: User 2 logged in
console.log("--- Fourth render (userId: 2, no change) ---");
runComponent(2); // No effect should run
// Simulate component unmount after third render (not explicitly part of the system, but good to show cleanup)
console.log("--- Simulated Unmount ---");
// In a real scenario, this would happen when the component is removed.
// For this simulation, we'd need a mechanism to trigger cleanups.
// For this challenge, we'll assume cleanups are triggered by dependency changes or a hypothetical unmount.
// The manual execution of cleanup for user 1 in the third render demonstrates the concept.
Output:
--- First render (userId: 1) ---
Component rendered with userId: 1
--- Second render (userId: 1, no change) ---
Component rendered with userId: 1
--- Third render (userId: 2, dependency change) ---
Cleanup for: User 1 logged in
Effect ran for: User 2 logged in
Component rendered with userId: 2
--- Fourth render (userId: 2, no change) ---
Component rendered with userId: 2
--- Simulated Unmount ---
Explanation:
The useLogger hook uses useEffect.
- On the first render with
userId: 1, the effect runs. - On the second render with the same
userId: 1, the dependency hasn't changed, so the effect does not run. - On the third render with
userId: 2, theuserIddependency has changed. The previous effect's cleanup function (foruserId: 1) is executed, and then the new effect foruserId: 2runs.
Example 3: Chaining Hooks
// Assume createHookSystem is available and has been called.
const { defineHook, runHook, runComponent } = createHookSystem();
// A hook to fetch data (simulated)
defineHook('useFetch', (url) => {
const [data, setData] = runHook('useState', null);
const [loading, setLoading] = runHook('useState', true);
const [error, setError] = runHook('useState', null);
runHook('useEffect', async () => {
setLoading(true);
setError(null);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 100));
const fetchedData = `Data from ${url}`;
setData(fetchedData);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
// No cleanup needed for this simple fetch
}, [url]); // Re-fetch if URL changes
return { data, loading, error };
});
// A hook that uses another hook
defineHook('useUserData', (userId) => {
const url = `/api/users/${userId}`;
const { data: userData, loading: userLoading, error: userError } = runHook('useFetch', url);
// We could do more processing or add another hook here
return { userData, userLoading, userError };
});
const componentState = {};
function runComponent(userId) {
const hookRunner = createHookRunner(componentState);
const runHookInContext = hookRunner.runHook;
const { userData, userLoading, userError } = runHookInContext('useUserData', userId);
if (userLoading) {
console.log(`Loading user ${userId}...`);
} else if (userError) {
console.log(`Error loading user ${userId}: ${userError}`);
} else if (userData) {
console.log(`User data for ${userId}: ${userData}`);
}
}
console.log("--- First render (userId: 1) ---");
runComponent(1); // Loading user 1... then User data for 1: Data from /api/users/1
console.log("--- Second render (userId: 1, delayed) ---");
// Simulate a small delay to see loading state if fetch was asynchronous
setTimeout(() => {
runComponent(1); // User data for 1: Data from /api/users/1
}, 200);
Output:
--- First render (userId: 1) ---
Loading user 1...
User data for 1: Data from /api/users/1
--- Second render (userId: 1, delayed) ---
User data for 1: Data from /api/users/1
Explanation:
useUserData calls useFetch. useFetch itself uses useState and useEffect. This demonstrates how custom hooks can be composed. The runHook system must correctly track and manage state across nested hook calls within the same component context.
Constraints
- Your
createHookSystemfunction should be self-contained and not rely on external libraries like React. - The hook system should handle up to 100 registered hooks.
- Each simulated component context should be able to manage state for up to 50 hook calls per render cycle.
useEffectdependency arrays will contain up to 10 elements.- Performance is not the primary concern for this challenge, but the implementation should be reasonably efficient and avoid excessive complexity.
Notes
- The core challenge is managing the "fiber" or execution context for hooks. Think about how React determines which state belongs to which hook call. The order of hook calls within a component's render is crucial.
- You'll need to design a way to store the state for each hook per simulated component instance. A map or object keyed by hook name and then perhaps by an index within that component's execution order might be a good starting point.
- Consider how
useState'ssetStatewill trigger a re-evaluation. In this simulation, you'll have to manually trigger re-renders (e.g., by callingrunComponentagain). In a real system, this would be an internal mechanism. - The
createHookRunnerfunction in the examples is a placeholder for how you might manage the context for a single "render" of a simulated component. You'll need to implement this or a similar mechanism withincreateHookSystem. - You'll likely need to implement a basic
useStateanduseEffecthook within your hook system itself, which your custom hooks can then call usingrunHook.