Build a React Selector Library in TypeScript
This challenge asks you to create a reusable React hook that simplifies the process of selecting and transforming data from a global state. Many applications require complex logic to derive specific pieces of information from a central store. A well-designed selector hook can improve performance by memoizing results and make your components cleaner and more readable.
Problem Description
You need to build a custom React hook, useSelector, that mimics the behavior of popular state management libraries' selectors. This hook will take a selector function as an argument and return the selected value from a conceptual global state.
Key Requirements:
useSelectorHook: Create a TypeScript hook nameduseSelector<TState, TSelected>.TState: The type of your global state.TSelected: The type of the value returned by the selector function.
- Selector Function: The hook should accept a
selectorfunction. This function will receive the current global state and return a derived value.- Signature:
(state: TState) => TSelected
- Signature:
- State Updates: The hook should subscribe to changes in the global state. When the state changes, it should re-run the
selectorfunction. - Return Value: The hook should return the result of the
selectorfunction. - Memoization: Crucially, the hook must memoize the result of the selector function. If the state changes but the result of the selector function does not change, the component using the hook should not re-render. This is a core performance optimization.
- Global State Simulation: For this challenge, you will simulate a global state and a mechanism to update it. You do not need to implement a full Redux-like store. A simple
useStatewithin a context provider can serve as your global state manager.
Expected Behavior:
- When a component mounts,
useSelectorshould execute the selector function with the current state and return the initial selected value. - When the global state is updated,
useSelectorshould re-evaluate the selector function. - If the new result of the selector function is different from the previously returned value, the component using the hook should re-render.
- If the new result of the selector function is the same as the previously returned value (e.g., using
===comparison), the component should not re-render, even though the global state has changed.
Edge Cases to Consider:
- Initial Render: Ensure the selector is called correctly on the first render.
- No State Change: Verify that components don't re-render if the state changes but the selector output remains the same.
- Complex Selectors: The memoization should work even for selectors that compute values based on multiple parts of the state.
- Function/Object Equality: The memoization should handle primitive types, objects, and arrays correctly. The common approach is reference equality for non-primitives.
Examples
Example 1: Simple Value Selection
// Assume a global state and a provider that manages it
interface AppState {
user: { name: string; id: number } | null;
theme: 'light' | 'dark';
}
const initialState: AppState = {
user: { name: 'Alice', id: 1 },
theme: 'light',
};
// Provider component (simplified for context)
// const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// const [state, setState] = useState(initialState);
// return (
// <AppContext.Provider value={{ state, setState }}>
// {children}
// </AppContext.Provider>
// );
// };
// In a component:
function UserNameDisplay() {
// Selector to get the user's name
const userName = useSelector((state: AppState) => state.user?.name);
console.log('UserNameDisplay rendered'); // This should only log when userName changes
return <div>User: {userName || 'Guest'}</div>;
}
// --- Scenario ---
// Initial State: { user: { name: 'Alice', id: 1 }, theme: 'light' }
//
// Initial Render of UserNameDisplay:
// useSelector((state) => state.user?.name) is called with state.
// Selector returns 'Alice'.
// UserNameDisplay renders for the first time.
// Console logs: "UserNameDisplay rendered"
//
// State Update:
// globalState = { user: { name: 'Bob', id: 2 }, theme: 'light' }
//
// Re-evaluation:
// useSelector is triggered.
// Selector is called with new state.
// Selector returns 'Bob'.
// 'Bob' !== 'Alice', so UserNameDisplay re-renders.
// Console logs: "UserNameDisplay rendered"
//
// State Update:
// globalState = { user: { name: 'Bob', id: 3 }, theme: 'dark' }
//
// Re-evaluation:
// useSelector is triggered.
// Selector is called with new state.
// Selector returns 'Bob'.
// 'Bob' === 'Bob', so UserNameDisplay does NOT re-render.
// Console logs: NO new log
Example 2: Derived Value (Object)
// Using the same AppState interface as above
function UserInfo() {
// Selector to get a derived object
const userInfo = useSelector((state: AppState) => ({
name: state.user?.name,
isLoggedIn: !!state.user,
}));
console.log('UserInfo rendered', userInfo); // Should only log when userInfo object changes reference
return (
<div>
<p>Name: {userInfo.name || 'Guest'}</p>
<p>Status: {userInfo.isLoggedIn ? 'Logged In' : 'Logged Out'}</p>
</div>
);
}
// --- Scenario ---
// Initial State: { user: { name: 'Alice', id: 1 }, theme: 'light' }
//
// Initial Render of UserInfo:
// useSelector((state) => ({ name: state.user?.name, isLoggedIn: !!state.user }))
// Selector returns { name: 'Alice', isLoggedIn: true }.
// UserInfo renders.
// Console logs: "UserInfo rendered { name: 'Alice', isLoggedIn: true }"
//
// State Update:
// globalState = { user: { name: 'Alice', id: 1 }, theme: 'dark' } (only theme changed)
//
// Re-evaluation:
// useSelector is triggered.
// Selector is called with new state.
// Selector returns { name: 'Alice', isLoggedIn: true }.
// The returned object reference is the SAME as before (or a deep comparison would yield the same result).
// UserInfo does NOT re-render.
// Console logs: NO new log
//
// State Update:
// globalState = { user: null, theme: 'dark' }
//
// Re-evaluation:
// useSelector is triggered.
// Selector is called with new state.
// Selector returns { name: undefined, isLoggedIn: false }.
// The returned object reference is DIFFERENT.
// UserInfo re-renders.
// Console logs: "UserInfo rendered { name: undefined, isLoggedIn: false }"
Constraints
- TypeScript: The solution must be written in TypeScript.
- Hook-based: The core functionality must be encapsulated in a custom React hook.
- Memoization Strategy: A common and acceptable memoization strategy is to store the last selected value and compare it with the new value using a strict equality check (
===). This works well for primitives and object references. - State Management Simulation: You need to simulate a state update mechanism. Using React's
useStatewithin aContext.Provideris the recommended approach for this simulation. - No External Libraries: Do not use any external state management libraries like Redux, Zustand, Jotai, etc., for implementing the
useSelectorhook itself. You can use them as inspiration.
Notes
- Think about how to manage subscriptions to state changes. When the state changes, how does your hook know to re-evaluate?
- The memoization is key to preventing unnecessary re-renders. Consider the equality check carefully.
- You'll need to provide a basic
AppContextanduseAppContexthook to access the simulated state and thesetStatefunction from your provider. - Focus on making the
useSelectorhook robust and efficient. The provided examples should serve as your primary testing ground.