reactjstypescriptreact-hooksreact-contextuse-reducer

the useReducer dispatch is not called in a callback


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


Solution

  • 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.