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;
}
};
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