Implement a useThunkReducer Hook in React
This challenge requires you to create a custom React hook called useThunkReducer. This hook should provide the functionality of React's built-in useReducer, but with the added capability to dispatch asynchronous actions (thunks) directly. This is a common pattern for managing complex state logic in React applications, especially when dealing with side effects.
Problem Description
You need to implement a custom React hook named useThunkReducer that mimics the behavior of React.useReducer but allows for the dispatching of functions (thunks) in addition to plain action objects.
Key Requirements:
- Initialization: The hook should accept a reducer function, an initial state, and an optional initializer function (similar to
useReducer). - State and Dispatch: It should return an array containing the current state and a
dispatchfunction, just likeuseReducer. - Thunk Dispatching: The
dispatchfunction returned byuseThunkReducermust be able to handle both:- Plain Action Objects: These should be passed directly to the underlying reducer.
- Thunk Functions: These are functions that receive the
dispatchfunction itself and the currentgetStatefunction as arguments. Thunks can then dispatch other actions (plain or thunks) or perform other asynchronous operations.
getStateFunction: Alongside thedispatchfunction, agetStatefunction should be made available to thunks, allowing them to access the current state.- Type Safety: The implementation should be fully typed using TypeScript, ensuring type safety for states, actions, and thunks.
Expected Behavior:
- When a plain action object is dispatched, the reducer should be called with the current state and the action object, and the state should be updated accordingly.
- When a thunk function is dispatched, it should be executed, receiving the
dispatchfunction andgetStatefunction as arguments. The thunk can then trigger further state updates by callingdispatchwith other actions or thunks. - The hook should correctly handle re-renders when state changes occur.
Edge Cases to Consider:
- Dispatching multiple thunks in succession.
- Thunks that dispatch other thunks.
- Handling of the initial state, especially when an initializer function is provided.
- Ensuring the
getStatefunction always returns the most up-to-date state.
Examples
Example 1: Basic Usage with Plain Actions
import React from 'react';
import { useThunkReducer } from './useThunkReducer'; // Assume your hook is in this file
interface CounterState {
count: number;
}
type CounterAction =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' };
const counterReducer = (state: CounterState, action: CounterAction): CounterState => {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
default:
return state;
}
};
function CounterComponent() {
const [state, dispatch] = useThunkReducer(counterReducer, { count: 0 });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>Increment</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>Decrement</button>
</div>
);
}
Input to hook: counterReducer, { count: 0 }
dispatch calls: dispatch({ type: 'INCREMENT' })
Output (UI): Renders a counter that increments/decrements.
Example 2: Usage with Thunks
import React from 'react';
import { useThunkReducer } from './useThunkReducer';
interface AsyncCounterState {
count: number;
isLoading: boolean;
}
type AsyncCounterAction =
| { type: 'INCREMENT' }
| { type: 'START_LOADING' }
| { type: 'STOP_LOADING' };
// Define a Thunk type for better type safety
type AsyncCounterThunk = (
dispatch: React.Dispatch<AsyncCounterAction | AsyncCounterThunk>,
getState: () => AsyncCounterState
) => void | Promise<void>;
const asyncCounterReducer = (state: AsyncCounterState, action: AsyncCounterAction): AsyncCounterState => {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + 1 };
case 'START_LOADING':
return { ...state, isLoading: true };
case 'STOP_LOADING':
return { ...state, isLoading: false };
default:
return state;
}
};
// A thunk to increment the counter after a delay
const delayedIncrement: AsyncCounterThunk = async (dispatch, getState) => {
dispatch({ type: 'START_LOADING' });
await new Promise(resolve => setTimeout(resolve, 1000));
// Using getState to check current state before dispatching
if (getState().count < 5) {
dispatch({ type: 'INCREMENT' });
}
dispatch({ type: 'STOP_LOADING' });
};
function AsyncCounterComponent() {
const [state, dispatch] = useThunkReducer(asyncCounterReducer, { count: 0, isLoading: false });
const handleDelayedIncrement = () => {
// Dispatching the thunk function
dispatch(delayedIncrement);
};
return (
<div>
<p>Count: {state.count}</p>
<p>Loading: {state.isLoading ? 'Yes' : 'No'}</p>
<button onClick={handleDelayedIncrement} disabled={state.isLoading}>
Delayed Increment
</button>
</div>
);
}
Input to hook: asyncCounterReducer, { count: 0, isLoading: false }
dispatch calls: dispatch(delayedIncrement)
Output (UI): When "Delayed Increment" is clicked, the UI shows "Loading: Yes" for 1 second, then increments the count if it's less than 5, and finally shows "Loading: No".
Example 3: Initializer Function
import React from 'react';
import { useThunkReducer } from './useThunkReducer';
interface InitializerState {
value: string;
}
type InitializerAction = { type: 'SET_VALUE', payload: string };
const initializerReducer = (state: InitializerState, action: InitializerAction): InitializerState => {
return action.payload ? { value: action.payload } : state;
};
// Custom initializer function that might fetch data or perform calculations
const initializeMyState = (): InitializerState => {
// In a real app, this could be an API call
const initialValue = localStorage.getItem('myAppState') || 'default from initializer';
return { value: initialValue };
};
function InitializerComponent() {
const [state, dispatch] = useThunkReducer(initializerReducer, { value: '' }, initializeMyState);
return (
<div>
<p>Initialized Value: {state.value}</p>
<input
type="text"
value={state.value}
onChange={(e) => dispatch({ type: 'SET_VALUE', payload: e.target.value })}
/>
</div>
);
}
Input to hook: initializerReducer, { value: '' }, initializeMyState
Output (UI): Renders an input field pre-filled with a value from localStorage or a default string.
Constraints
- The hook must be implemented in TypeScript.
- The hook should leverage React's
useReducerinternally or implement similar logic. - The
dispatchfunction must handle both action objects and thunk functions. - The
getStatefunction must be accessible to thunks. - Consider potential performance implications for very frequent dispatches or complex thunks.
Notes
- Think about how to correctly type the
dispatchfunction to accept both action types and thunk types. - Consider using generics to make your hook reusable with any state and action types.
- The
getStatefunction is crucial for thunks to be aware of the current application state before making decisions. - The use of
React.Dispatchwill be helpful for typing. - You might need to define custom types for your thunks to ensure type safety.