0

I hope someone can help me with that. I'm experience the following using the React useReducer:

I need to search for items in a list. I'm setting up a global state with a context:

Context

const defaultContext = [itemsInitialState, (action: ItemsActionTypes) => {}];
const ItemContext = createContext(defaultContext);

const ItemProvider = ({ children }: ItemProviderProps) => {
    const [state, dispatch] = useReducer(itemsReducer, itemsInitialState);
    const store = useMemo(() => [state, dispatch], [state]);
    return <ItemContext.Provider value={store}>{children}</ItemContext.Provider >;
};

export { ItemContext, ItemProvider };

and I created a reducer in a separate file:

Reducer

export const itemsInitialState: ItemsState = {
    items: [],
};

export const itemsReducer = (state: ItemsState, action: ItemsActionTypes) => {
    const { type, payload } = action;
    
    switch (type) {
        case GET_ITEMS:
            return {
                ...state,
                items: payload.items,
            };

        default:
            throw new Error(`Unsupported action type: ${type}`);
    }
};

I created also a custom hook where I call the useContext() and a local state to get the params from the form:

custom hook

export const useItems = () => {
    const context = useContext(ItemContext);
    if (!context) {
        throw new Error(`useItems must be used within a ItemsProvider`);
    }
    const [state, dispatch] = context;

    const [email, setEmail] = useState<string>('');
    const [title, setTitle] = useState<string>('');
    const [description, setDescription] = useState<string>('');
    const [price, setPrice] = useState<string>('');

    const [itemsList, setItemsList] = useState<ItemType[]>([]);

    const onChangeEmail = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void =>
        setEmail(e.currentTarget.value);
    const onChangeTitle = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void =>
        setTitle(e.currentTarget.value);
    const onChangePrice = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void =>
        setPrice(e.currentTarget.value);
    const onChangeDescription = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void =>
        setDescription(e.currentTarget.value);

    const handleSearch = useCallback(
        async (event: React.SyntheticEvent) => {
            event.preventDefault();
            const searchParams = { email, title, price, description };
            const { items } = await fetchItemsBatch({ searchParams });

            if (items) {
                setItemsList(items);
                if (typeof dispatch === 'function') {
                    console.log('use effect');
                    dispatch({ type: GET_ITEMS, payload: { items } });
                }
            }
        },
        [email, title, price, description]
    );

    // useEffect(() => {
    //     // add a 'type guard' to prevent TS union type error
    //     if (typeof dispatch === 'function') {
    //         console.log('use effect');
    //         dispatch({ type: GET_ITEMS, payload: { items: itemsList } });
    //     }
    // }, [itemsList]);

    return {
        state,
        dispatch,
        handleSearch,
        onChangeEmail,
        onChangeTitle,
        onChangePrice,
        onChangeDescription,
    };
};

this is the index:

function ItemsManagerPageHome() {
    const { handleSearch, onChangeEmail, onChangePrice, onChangeTitle, onChangeDescription } = useItems();

    return (
        <ItemProvider>
            <Box>
                <SearchComponent
                    handleSearch={handleSearch}
                    onChangeEmail={onChangeEmail}
                    onChangePrice={onChangePrice}
                    onChangeTitle={onChangeTitle}
                    onChangeDescription={onChangeDescription}
                />
                <ListContainer />
            </Box>
        </ItemProvider>
    );
}

The ListContainer should then do this to get values from the global state:

 const { state } = useItems();

The issue is that when I try to dispatch the action after the list items are fetched the reducer is not called, and I cannot figure out why. I try to put the dispatch in a useEffect() trying to trigger it only when a listItems state changes but I can see it called only at the beginning and not when the callback is fired.

What am I doing wrong?

Thank you for the help

andrixb
  • 121
  • 1
  • 10

1 Answers1

1

You should use ItemsManagerPageHome component as a descendant component of the ItemProvider component. So that you can useContext(ItemContext) to get the context value from ItemContext.Provider.

Besides, I saw you validate that useItems must be used in ItemsProvider, but the if condition always is false because the defaultContext is an array and it's always a truth value. So, your validation doesn't work. You can use a null value as the default context.

The correct way is:

context.tsx:

import { createContext, useMemo, useReducer } from 'react';
import * as React from 'react';

type ItemProviderProps = any;
type ItemsActionTypes = any;
type ItemsState = any;

export const GET_ITEMS = 'GET_ITEMS';

export const itemsInitialState: ItemsState = {
  items: [],
};

export const itemsReducer = (state: ItemsState, action: ItemsActionTypes) => {
  const { type, payload } = action;

  switch (type) {
    case GET_ITEMS:
      return {
        ...state,
        items: payload.items,
      };

    default:
      throw new Error(`Unsupported action type: ${type}`);
  }
};

const ItemContext = createContext(null);

const ItemProvider = ({ children }: ItemProviderProps) => {
  const [state, dispatch] = useReducer(itemsReducer, itemsInitialState);
  const store = useMemo(() => [state, dispatch], [state]);
  return <ItemContext.Provider value={store}>{children}</ItemContext.Provider>;
};

export { ItemContext, ItemProvider };

hooks.ts:

import { useCallback, useContext, useState } from 'react';
import { GET_ITEMS, ItemContext } from './context';

type ItemType = any;

const fetchItemsBatch = (): Promise<{ items: ItemType[] }> =>
  new Promise((resolve) =>
    setTimeout(() => resolve({ items: [1, 2, 3] }), 1_000)
  );

export const useItems = () => {
  const context = useContext(ItemContext);
  if (!context) {
    throw new Error(`useItems must be used within a ItemsProvider`);
  }
  const [state, dispatch] = context;

  const handleSearch = useCallback(async (event: React.SyntheticEvent) => {
    event.preventDefault();
    const { items } = await fetchItemsBatch();
    if (items) {
      if (typeof dispatch === 'function') {
        dispatch({ type: GET_ITEMS, payload: { items } });
      }
    }
  }, []);

  return {
    state,
    dispatch,
    handleSearch,
  };
};

ItemsManagerPageHome.tsx:

import React = require('react');
import { useItems } from './hooks';

export function ItemsManagerPageHome() {
  const { handleSearch, state } = useItems();
  console.log('state: ', state);

  return <input onClick={handleSearch} type="button" value="search" />;
}

App.tsx:

import * as React from 'react';
import { ItemProvider } from './context';
import { ItemsManagerPageHome } from './ItemsManagerPageHome';
import './style.css';

export default function App() {
  return (
    <div>
      <ItemProvider>
        <ItemsManagerPageHome />
      </ItemProvider>
    </div>
  );
}

Demo: stackblitz

Click the "search" button and see the logs in the console.

Lin Du
  • 88,126
  • 95
  • 281
  • 483