reactjsreact-hooksuse-reducer

custom hook not reading value updated by reducer


I have a page with a Table, this table is controlled with other inputs which are put in common through a custom hook, I try to read the values in the hook in the page where the component is but although the values inside the hook are updated they are not read in the page

to clarify

[Page] - contains -> [ table ] - controlled by [inputs in page]

// Due to a complex state, the state of the hook is controlled by a reducer

this is a smaller version of codesandbox reproducing the issue. https://codesandbox.io/p/sandbox/charming-lumiere-vyoewu?file=%2Fsrc%2Freducers%2FpageQueryReducer.ts&selection=%5B%7B%22endColumn%22%3A3%2C%22endLineNumber%22%3A67%2C%22startColumn%22%3A3%2C%22startLineNumber%22%3A67%7D%5D

// Hook
    import {
  PageQueryActionKind,
  pageQueryReducer,
  queryInit,
} from "../reducers/pageQueryReducer";
import { useEffect, useReducer } from "react";

const useTable = () => {
  const [state, dispatch] = useReducer(pageQueryReducer, queryInit);
  useEffect(() => {
    console.log("state read by the hook", state);
  }, [state]);
  const handlePageChange = (page: number) => {
    dispatch({
      type: PageQueryActionKind.SET_PAGE,
      payload: {
        page,
        per_page: state.perPage,
      },
    });
  };

  const handlePerPageChange = (perPage: number) => {
    dispatch({
      type: PageQueryActionKind.SET_PAGE,
      payload: { page: state.page, per_page: perPage },
    });
  };

  const handleSortChange = (column: string, direction: "asc" | "desc" | "") => {
    dispatch({
      type: PageQueryActionKind.SET_COL_SORT,
      payload: {
        columnSortItem: column,
        columnOrder: direction,
      },
    });
  };
  return {
    currentPage: state.page,
    setCurrentPage: handlePageChange,
    entriesPerPage: state.perPage,
    setEntriesPerPage: handlePerPageChange,
    columnSortDirection: state.columnOrder,
    currentSortedColumn: state.columnSortItem,
    setColumnSort: handleSortChange,
    tableFilters: state.tableFilters,
    queryString: state.queryString,
    overAllState: state,
  };
};

export default useTable;

// Components

const Checkbox = ({ label, onChangeFunc }) => {
  return (
    <div className="checkbox-wrapper">
      <label>
        <input type="checkbox" onChange={onChangeFunc} />
        <span>{label}</span>
      </label>
    </div>
  );
};

export default Checkbox;

import Checkbox from "./checkbox";
import useTable from "../hooks/useTable";

const Table = () => {
  const { setColumnSort } = useTable();
  return (
    <div>
      Test quote and quote table
      <Checkbox
        label="test"
        onChangeFunc={() => setColumnSort("hipothethicalColumn", "asc")}
      />
    </div>
  );
};

export default Table;

// Reducer

    type TableFilters = {
  col: string;
  order: string;
};

interface State {
  page: number;
  perPage: number;
  tableFilters: TableFilters[];
  columnSortItem: string;
  columnOrder: "asc" | "desc" | "";
  queryString: string;
}

export enum PageQueryActionKind {
  SET_PAGE = "set_page",
  SET_COL_SORT = "set_col_sort",
  SET_FILTER = "set_filter",
  SET_ROWS_PER_PAGE = "set_rows_per_page",
  RESET = "reset",
}

interface SetPageAction {
  type: PageQueryActionKind.SET_PAGE;
  payload: {
    page: number;
    per_page: number;
  };
}

interface SetColSortAction {
  type: PageQueryActionKind.SET_COL_SORT;
  payload: {
    columnSortItem: string;
    columnOrder: "asc" | "desc" | "";
  };
}

interface SetFilterAction {
  type: PageQueryActionKind.SET_FILTER;
  payload: {
    filter: string;
    value: string;
  };
}

interface SetRowsPerPageAction {
  type: PageQueryActionKind.SET_ROWS_PER_PAGE;
  payload: number;
}

type Actions =
  | SetPageAction
  | SetColSortAction
  | SetFilterAction
  | SetRowsPerPageAction
  | { type: PageQueryActionKind.RESET; payload: undefined };

export const queryInit = {
  page: 1,
  perPage: 25,
  tableFilters: [],
  columnSortItem: "intervention_code",
  columnOrder: "desc",
  queryString:
    "/afm_interventions?company_id=1&page=1&per_page=25&order_by=intervention_code&order=desc",
};

export const pageQueryReducer = (state: State, action: Actions): State => {
  console.log("reducer prev values and action", { action, state });
  switch (action.type) {
    case PageQueryActionKind.SET_PAGE:
      return {
        ...state,
        page: action.payload.page,
        perPage: action.payload.per_page,
        queryString: state.queryString.replace(
          /page=[0-9]+&per_page=[0-9]+/,
          `page=${action.payload.page}&per_page=${action.payload.per_page}`
        ),
      };
    case PageQueryActionKind.SET_COL_SORT:
      return {
        ...state,
        columnSortItem: action.payload.columnSortItem,
        columnOrder: action.payload.columnOrder,
        queryString: state.queryString.replace(
          /order_by=[a-z_]+&order=[a-z]+/,
          `order_by=${action.payload.columnSortItem}&order=${action.payload.columnOrder}`
        ),
      };
    case PageQueryActionKind.SET_FILTER:
      if (
        state.tableFilters.find(
          (tableFilter) => tableFilter.col === action.payload.filter
        )
      ) {
        return {
          ...state,
          tableFilters: state.tableFilters.map((tableFilter) => {
            if (tableFilter.col === action.payload.filter) {
              return {
                ...tableFilter,
                order: action.payload.value,
              };
            }
            return tableFilter;
          }),
        };
      }
      return {
        ...state,
        tableFilters: [
          ...state.tableFilters,
          { col: action.payload.filter, order: action.payload.value },
        ],
      };
    case PageQueryActionKind.SET_ROWS_PER_PAGE:
      return {
        ...state,
        perPage: action.payload,
      };
    case PageQueryActionKind.RESET:
      return queryInit;
    default:
      return state;
  }
};

Solution

  • there's no shared state

    If I'm looking at it right, MainPage and Table each have their own instance of useTable and therefore each have their own state, reducer, and dispatch. To see the issue, add a useEffect to MainPage to see that clicking the checkbox does not cause re-render in main page. Then add one to Table to see that it does change.

    The hint that the state is separate/disconnected is that <Table/> is created without any props. The easiest option is to take the hook's return value and pass it to the table as a prop -

    import Table from "../components/table";
    import useTable from "../hooks/useTable";
    const MainPage = () => {
      const table = useTable(); // ✅ table state and dispatch
      return (
        <div>
          <p>{table.currentSortedColumn}</p>
          <p>{table.columnSortDirection}</p>
          <Table table={table} /> {/* ✅ pass table as prop */ }
        </div>
      );
    };
    
    const Table = ({ table }) => { // ✅ table available through prop
      // ✅ useTable hook no longer necessary
      return (
        <div>
          Test quote and quote table
          <Checkbox
            label="test"
            onChangeFunc={() => table.setColumnSort("hipothethicalColumn", "asc")}
          />
        </div>
      );
    };
    

    faux complexity

    "Due to a complex state, the state of the hook is controlled by a reducer." The complexity you are experiencing is unfortunately self-induced. First I will mention that queryString is derived state and shouldn't be stored as state of it its own. It can always be determined from the state of the other values, so remove that from the state model -

    type State = { // ✅ interface is for extensible types
      page: number;
      perPage: number;
      tableFilters: TableFilters[];
      columnSortItem: string;
      columnOrder: "asc" | "desc" | "";
      // queryString: string; ❌ remove derived state
    }
    
    export const initState: State = {
      page: 1,
      perPage: 25,
      tableFilters: [],
      columnSortItem: "intervention_code",
      columnOrder: "desc",
      // queryString: ... ❌ remove derived state
    };
    

    The hook is dramatically simplified. No need for a laundry list of action types and custom state handlers. useState can handle all state changes and useMemo can compute queryString -

    const useTable = () => {
      const [page, setPage] = useState(initState.page)
      const [perPage, setPerPage] = useState(initState.perPage)
      const [filters, setFilters] = useState(initState.tableFilters)
      const [columnSortItem, setColumnSortItem] = useState(initState.columnSortItem)
      const [columnOrder, setColumnOrder] = useState(initState.columnOrder)
    
      // ✅ queryString is derived state
      const queryString = useMemo(
        () => {
          const q = new URLSearchParams() // ✅ don't use string.replace!
          q.set("company_id", 1)
          q.set("page", page)
          q.set("per_page", perPage)
          q.set("order_by", columnSortItem)
          q.set("order", columnOrder)
          return String(q) // company_id=1&page=1&per_page=25&...
        },
        [page, perPage, columnSortItem, columnOrder]
      }
    
      return {
        page, setPage,
        perPage, setPerPage,
        filters, setFilters,
        columnSortItem, setColumnSortItem,
        columnOrder, setColumnOrder,
        queryString
      }
    };
    

    filters and sorting

    The proposed reducer also shows an opportunity to improve filters in a big way. a Map of column to value provides constant-time read/write, more appropriate than array which requires linear scan -

    type State = {
      page: number;
      perPage: number;
      tableFilters: Map<string, string>; // ✅ 
      columnSortItem: string;
      columnOrder: "asc" | "desc" | "";
    }
    
    export const initState: State = {
      page: 1,
      perPage: 25,
      tableFilters: new Map(), // ✅
      columnSortItem: "intervention_code",
      columnOrder: "desc",
    };
    

    To make the hook more usable, we will expose a custom setFilter(column, value) function that calls setFilters behind the scenes -

    const useTable = () => {
      const [page, setPage] = ...
      const [perPage, setPerPage] = ...
      const [filters, setFilters] = ...
      const [columnSortItem, setColumnSortItem] = ...
      const [columnOrder, setColumnOrder] = ...
      const queryString = ...
    
      function setFilter(column, value) {
        // ✅ calls setFilters
        // ✅ immutable Map update
        setFilters(prevState => new Map(prevState).set(column, value))
      }
    
      return {
        page, setPage,
        perPage, setPerPage,
        filters, setFilter, // ✅ setFilter (not setFilters)
        columnSortItem, setColumnSortItem,
        columnOrder, setColumnOrder,
        queryString
      }
    };
    

    To set a filter, you can create inputs that set a filter for a specified column -

    <input
      value={filters.get("name") ?? ""}
      onChange={e => setFilter("name", e.target.value)}
    />
    

    To display the filtered rows, you simply .filter your tableRows data to ensure the data matches the filters state, .sort, then .map the result to display each row that passes the filters -

    <table>
      <thead>...</thead>
      <tbody>
        {tableRows
           .filter(row =>
             Array.from(filters.entries()).every(([column value]) =>
               value == "" || row[column] == value
             )
           )
           .sort((a,b) => {
             if (columnSortItem && columnSortOrder) {
               return columnSortOrder == "asc"
                 ? a[columnSortItem].localeCompare(b[columnSortItem])
                 : b[columnSortItem].localeCompare(a[columnSortItem])
             }
             else {
               return 0 // unsorted
             }
           })
           .map((row, key) =>
             <TableRow key={key} data={row} />
           )
        }
      </tbody>
    <table>
    

    useContext

    If you want to share state across components without sending them through props, consider createContext and useContext -

    const TableContext = createContext({ ... }) // ✅ context is shared state
    
    function useTable() { // ✅ useTable exposes shared state
      return useContext(TableContext)
    }
    
    function TableProvider({ children }) {
      const [page, setPage] = ...
      const [perPage, setPerPage] = ...
      const [filters, setFilters] = ...
      const [columnSortItem, setColumnSortItem] = ...
      const [columnOrder, setColumnOrder] = ...
      const queryString = ...
    
      function setFilter(column, value) {
        setFilters(prevState => new Map(prevState).set(column, value))
      }
    
      const state = { // ✅
        page, setPage,
        perPage, setPerPage,
        filters, setFilter,
        columnSortItem, setColumnSortItem,
        columnOrder, setColumnOrder,
        queryString
      }
    
      return <TableContext.Provider value={state}>
        {children} // ✅ all children can access shared state
      </Table>
    }
    
    export { TableProvider, useTable }
    

    Create a <TableProvider> boundary for all components that should be able to access the shared state -

    import { TableProvider } from "./table"
    import PageHeader from "./pageheader"
    import TableControls from "./tablecontrols"
    import TableData from "./tabledata"
    
    <TableProvider>
      <PageHeader />
      <TableControls />
      <TableData data={...} />
    </TableProvider>
    

    Now all child components simply call useTable to access the shared state -

    import { useTable } from "./table"
    
    function TableControls() {
      const {...} = useTable() // ✅ access shared state
      return ...
    }
    
    export default TableControls
    
    import { useTable } from "./table"
    
    function TableData({ data }) {
      const {...} = useTable() // ✅ access shared state
      return ...
    }
    
    export default TableData