0

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;
  }
};
Marcos Collado
  • 41
  • 1
  • 16
  • 1
    Even though a code paste is provided, StackOverflow asks that you paste all relevant code in your question. This ensures future visitors will always be able to see pertinent details, even after external sites die and links rot. – Mulan Mar 04 '23 at 13:30

1 Answers1

2

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
Mulan
  • 129,518
  • 31
  • 228
  • 259
  • First of all I am really thankful for the time you invested in reviewing my code and pointing this, but this doesn't clarify why my value isn't read when I see the reducer and state of the hook are updated. I have considered using a provider but I do not want anyone using my table having to use a provider, my intention is to just control the table however you like with the hook. the empty string is necessary because this controls wether the table is sorted in 'asc', 'desc' or not sorted at all, it controls a sort button in the headers. – Marcos Collado Mar 04 '23 at 14:28
  • short answer is "this would work" but its not how our table needs to work – Marcos Collado Mar 04 '23 at 14:30
  • 1
    _"my value isn't read when I see the reducer and state of the hook are updated"_ - please read the first paragraph closely. the value **is** read, it's just that `MainPage` has its own state, separate from the state of `Table`. The state of `MainPage` doesn't change, when `Table` state changes. To share state (in practicality) your options are to use context or pass via props. wrt: sorting, that's fair, you can accept non-sorted data. I will update that part of the post. – Mulan Mar 04 '23 at 14:36
  • 1
    "I do not want anyone using my table having to use a provider," this is not a requirement. I updated the post with a `useContext` example. – Mulan Mar 04 '23 at 15:06
  • 1
    I have to thank you again, because this whole problem happened to me due to a misconception of how my custom hook worked you see, I thought of it this way, I set the internal state of my custom hook with the exposed parts (just as in the context) and I just drag it around like a bag and read it like I want, thank you for pointing it out – Marcos Collado Mar 04 '23 at 19:00
  • very happy to help. if you have any follow up questions don't be shy ^_^ – Mulan Mar 05 '23 at 15:38