Hone logo
Hone
Problems

Build Your Own Redux: A State Management Foundation

This challenge asks you to implement the core functionalities of Redux from scratch using TypeScript within a React context. Understanding how state management libraries like Redux work under the hood is crucial for debugging complex applications and making informed decisions about state architecture. You will create a simplified version of Redux, focusing on its fundamental principles: the store, reducers, and dispatching actions.

Problem Description

Your task is to create a minimalist Redux-like state management system that can be integrated into a React application. You need to implement the following core components:

  • createStore function: This function will initialize your Redux store. It should accept an initial state and a reducer function.
  • Store object: The object returned by createStore should have the following methods:
    • getState(): Returns the current state of the store.
    • dispatch(action): Accepts an action object and passes it to the reducer. The reducer will return a new state, which the store will then update.
    • subscribe(listener): Registers a callback function (listener) to be called whenever the state changes. The subscribe method should return an unsubscribe function that can be called to remove the listener.
  • Reducer function: A pure function that takes the current state and an action as arguments and returns the new state.
  • Action object: A plain JavaScript object with a type property (string) and optionally other payload properties.

You will need to use TypeScript generics to ensure type safety for your state and actions. Your implementation should be independent of React's rendering lifecycle, but designed to be used with it.

Key Requirements:

  1. createStore Signature: createStore<S, A>(reducer: Reducer<S, A>, initialState: S): Store<S, A>
    • S represents the state type.
    • A represents the action type.
    • Reducer<S, A> is a type for your reducer function: (state: S | undefined, action: A) => S.
    • Store<S, A> is a type for your store object.
  2. Store Methods: Implement getState, dispatch, and subscribe as described above.
  3. Immutability: Reducers must not mutate the existing state directly. They should always return a new state object.
  4. Type Safety: Utilize TypeScript generics extensively to enforce type checking on state and actions.
  5. Listener Management: The unsubscribe function returned by subscribe must correctly remove the associated listener, preventing it from being called after unsubscription.

Expected Behavior:

  • Calling getState should return the current state.
  • Dispatching an action should trigger the reducer, update the store's state, and notify all subscribed listeners.
  • Subscribed listeners should be called with the latest state after the state has been updated.
  • Unsubscribing a listener should prevent it from being called on subsequent state changes.

Edge Cases to Consider:

  • What happens if the initial state is undefined? The reducer should handle this and potentially return a default initial state.
  • Ensuring listeners are not called after being unsubscribed.
  • Handling multiple subscriptions and unsubscriptions.

Examples

Example 1: Basic Counter

// Action types
interface IncrementAction {
  type: 'INCREMENT';
}

interface DecrementAction {
  type: 'DECREMENT';
}

type CounterAction = IncrementAction | DecrementAction;

// Reducer
const counterReducer = (state: number = 0, action: CounterAction): number => {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1;
    case 'DECREMENT':
      return state - 1;
    default:
      return state;
  }
};

// Store creation
const store = createStore(counterReducer, 0);

// Subscribing to state changes
let currentState = store.getState();
const unsubscribe = store.subscribe(() => {
  const newState = store.getState();
  console.log(`State changed from ${currentState} to ${newState}`);
  currentState = newState;
});

// Dispatching actions
store.dispatch({ type: 'INCREMENT' }); // Logs: State changed from 0 to 1
store.dispatch({ type: 'INCREMENT' }); // Logs: State changed from 1 to 2
store.dispatch({ type: 'DECREMENT' }); // Logs: State changed from 2 to 1

// Unsubscribing
unsubscribe();

// Dispatching after unsubscribe should not log anything
store.dispatch({ type: 'INCREMENT' });

Input: (as demonstrated in the code above) createStore(counterReducer, 0) store.dispatch({ type: 'INCREMENT' }) store.dispatch({ type: 'INCREMENT' }) store.dispatch({ type: 'DECREMENT' })

Output:

State changed from 0 to 1
State changed from 1 to 2
State changed from 2 to 1

Explanation: The createStore function initializes the store with an initial state of 0 and the counterReducer. When actions are dispatched, the reducer updates the state, and the subscribed listener logs the changes. After unsubscribing, further dispatches do not trigger the listener.

Example 2: Handling Undefined Initial State

interface User {
  name: string;
  age: number | null;
}

interface SetNameAction {
  type: 'SET_NAME';
  payload: string;
}

interface SetAgeAction {
  type: 'SET_AGE';
  payload: number;
}

type UserAction = SetNameAction | SetAgeAction;

const userReducer = (state: User | undefined, action: UserAction): User => {
  if (state === undefined) {
    // Provide a default initial state if none is given
    return { name: 'Guest', age: null };
  }
  switch (action.type) {
    case 'SET_NAME':
      return { ...state, name: action.payload };
    case 'SET_AGE':
      return { ...state, age: action.payload };
    default:
      return state;
  }
};

const userStore = createStore(userReducer); // No initial state provided

console.log(userStore.getState()); // Output: { name: 'Guest', age: null }

userStore.dispatch({ type: 'SET_NAME', payload: 'Alice' });
console.log(userStore.getState()); // Output: { name: 'Alice', age: null }

Input: createStore(userReducer) userStore.dispatch({ type: 'SET_NAME', payload: 'Alice' })

Output:

{ name: 'Guest', age: null }
{ name: 'Alice', age: null }

Explanation: When createStore is called without an initial state, the userReducer correctly initializes the state with its default values because it checks for state === undefined.

Constraints

  • The implementation should be entirely in TypeScript.
  • No external libraries (like the actual Redux library) are allowed for the core createStore, dispatch, getState, and subscribe logic. You can use React for demonstration purposes if you wish, but your core Redux implementation must be standalone.
  • The dispatch function should not return anything.
  • The getState function should always return the current state synchronously.
  • The number of listeners subscribed to the store should not be limited beyond typical JavaScript memory constraints.

Notes

  • Focus on the core mechanics of state management: a single source of truth, state immutability, and a predictable way to update state.
  • Consider how you will manage the list of listeners and ensure they are invoked correctly.
  • Think about the return type of createStore and how to define the Store interface with generics.
  • The reducer function signature is crucial for type safety. Ensure it can handle an undefined initial state gracefully.
Loading editor...
typescript