In React development, custom hooks are a handy tool for sharing logic between components, but they're not limited to just that. They are particularly useful for managing complex state interactions or combining multiple hooks. In this article, we'll explore how custom hooks can be used to create a sortable table in a React app. We'll discuss their benefits and show how to implement them for this specific use case.
The problem
In many situations in React development, when we need to add complex features, we often find it hard to do so in a way that keeps the code clean and easy to manage. If we don't have a clear plan, putting this feature directly into the component can make the code too complicated and hard to work with. In this case, let's consider a very simple example:
import { useState } from 'react' | |
import styles from './SortableTable.module.css' | |
export type TableDataItem = { | |
id: string | |
[key: string]: string | |
} | |
interface SortableTableProps { | |
data: TableDataItem[] | |
columns: string[] | |
} | |
export default function SortableTable({ data, columns }: SortableTableProps) { | |
const [state, setState] = useState({ | |
sortedData: data, | |
sortKey: columns[0], | |
sortDirection: 'asc', | |
}) | |
const sortByKey = (key: string) => { | |
const direction = | |
key === state.sortKey && state.sortDirection === 'asc' ? 'desc' : 'asc' | |
const sorted = [...data].sort((a, b) => { | |
if (a[key] < b[key]) return direction === 'asc' ? -1 : 1 | |
if (a[key] > b[key]) return direction === 'asc' ? 1 : -1 | |
return 0 | |
}) | |
setState({ | |
sortedData: sorted, | |
sortKey: key, | |
sortDirection: direction, | |
}) | |
} | |
return ( | |
<table className={styles.sortableTable}> | |
<thead> | |
<tr> | |
{columns.map((column) => ( | |
<th key={column} onClick={() => sortByKey(column)}> | |
{column} | |
{state.sortKey === column && ( | |
<span>{state.sortDirection === 'asc' ? '▲' : '▼'}</span> | |
)} | |
</th> | |
))} | |
</tr> | |
</thead> | |
<tbody> | |
{state.sortedData.map((item) => ( | |
<tr key={item.id}> | |
{columns.map((column) => ( | |
<td key={column}>{item[column]}</td> | |
))} | |
</tr> | |
))} | |
</tbody> | |
</table> | |
) | |
} |
Understanding Custom Hook Controllers
React's custom hook controllers serve as central hubs for managing complex state and behavior across components. They enhance the concept of custom hooks by providing a structured approach to encapsulate detailed logic. By consolidating logic within these controllers, developers can streamline their codebases, making them cleaner and more manageable. This organizational approach facilitates code comprehension, debugging, and collaboration. Additionally, the modular nature of custom hook controllers simplifies testing, as logic is encapsulated within reusable functions.
Implementing the pattern
Let's review the previous code and see how we can improve it using custom hook controllers. We'll start by refining the existing code to create a custom hook controller named useSortableTable, which effectively encapsulates the sorting logic:
// useSortableTable.ts | |
import { useState } from 'react' | |
export type TableDataItem = { | |
id: string | |
[key: string]: string | |
} | |
type SortableTableState = { | |
sortedData: TableDataItem[] | |
sortKey: string | |
sortDirection: string | |
} | |
type UseSortableTableReturnType = SortableTableState & { | |
sortByKey: (key: string) => void | |
} | |
export default function useSortableTable( | |
data: TableDataItem[], | |
defaultSortKey: string | |
): UseSortableTableReturnType { | |
const [state, setState] = useState<SortableTableState>({ | |
sortedData: data, | |
sortKey: defaultSortKey, | |
sortDirection: 'asc', | |
}) | |
const sortByKey = (key: string) => { | |
const direction = | |
key === state.sortKey && state.sortDirection === 'asc' ? 'desc' : 'asc' | |
const sorted = [...state.sortedData].sort((a, b) => { | |
if (a[key] < b[key]) return direction === 'asc' ? -1 : 1 | |
if (a[key] > b[key]) return direction === 'asc' ? 1 : -1 | |
return 0 | |
}) | |
setState({ | |
sortedData: sorted, | |
sortKey: key, | |
sortDirection: direction, | |
}) | |
} | |
return { | |
...state, | |
sortByKey, | |
} | |
} |
Now that we have defined the useSortableTable
custom hook controller, let's integrate it into our table:
// SortableTable.tsx | |
import useSortableTable, { TableDataItem } from './useSortableTable' | |
interface TableComponentProps { | |
data: TableDataItem[] | |
columns: string[] | |
} | |
export default function SortableTable({ data, columns }: TableComponentProps) { | |
const { sortedData, sortKey, sortDirection, sortByKey } = useSortableTable( | |
data, | |
columns[0] | |
) | |
return ( | |
<table> | |
<thead> | |
<tr> | |
{columns.map((column) => ( | |
<th key={column} onClick={() => sortByKey(column)}> | |
{column} | |
{sortKey === column && ( | |
<span>{sortDirection === 'asc' ? '▲' : '▼'}</span> | |
)} | |
</th> | |
))} | |
</tr> | |
</thead> | |
<tbody> | |
{sortedData.map((item) => ( | |
<tr key={item.id}> | |
{columns.map((column) => ( | |
<td key={column}>{item[column]}</td> | |
))} | |
</tr> | |
))} | |
</tbody> | |
</table> | |
) | |
} |
In this updated SortableTable
, we import and use the useSortableTable
hook controller. It returns the sorted data (sortedData
), the current sort key (sortKey
), the sort direction (sortDirection
), and a function to toggle sorting by key (sortByKey
). We utilize these values to render the table headers with appropriate sorting indicators and to display the sorted data in the table body.
Adding new features
One of the advantages of using custom hook controllers is the ease of adding new features to your components. Let's enhance our sortable table component by adding a feature to toggle the selection of table rows.
We can simply add selected
property in each TableDataItem
, which indicates whether an item is selected or not. To implement the toggle selection feature, we can utilize this property along with the toggleSelect
function provided by our useSortableTable
hook controller.
import { useReducer } from 'react' | |
export type TableDataItem = { | |
id: string | |
selected?: boolean | |
[key: string]: string | boolean | null | undefined | |
} | |
type SortDirection = 'asc' | 'desc' | |
type SortableTableState = { | |
sortedData: TableDataItem[] | |
sortKey: string | |
sortDirection: SortDirection | |
} | |
enum ActionType { | |
SORT = 'SORT', | |
TOGGLE_SELECT = 'TOGGLE_SELECT', | |
} | |
type SortPayload = { | |
key: string | |
direction: SortDirection | |
} | |
type ToggleSelectPayload = { | |
id: string | |
} | |
type Action = | |
| { type: ActionType.SORT; payload: SortPayload } | |
| { type: ActionType.TOGGLE_SELECT; payload: ToggleSelectPayload } | |
const sortData = (state: SortableTableState, payload: SortPayload) => { | |
const { key, direction } = payload | |
const sorted = [...state.sortedData].sort((a, b) => { | |
const valueA = a[key] || '' | |
const valueB = b[key] || '' | |
if (valueA < valueB) return direction === 'asc' ? -1 : 1 | |
if (valueA > valueB) return direction === 'asc' ? 1 : -1 | |
return 0 | |
}) | |
return { | |
...state, | |
sortedData: sorted, | |
sortKey: key, | |
sortDirection: direction, | |
} | |
} | |
const toggleSelect = ( | |
state: SortableTableState, | |
payload: ToggleSelectPayload | |
) => { | |
const { id } = payload | |
const updatedData = state.sortedData.map((item) => | |
item.id === id ? { ...item, selected: !item.selected } : item | |
) | |
return { | |
...state, | |
sortedData: updatedData, | |
} | |
} | |
const sortReducer = (state: SortableTableState, action: Action) => { | |
switch (action.type) { | |
case ActionType.SORT: | |
return sortData(state, action.payload) | |
case ActionType.TOGGLE_SELECT: | |
return toggleSelect(state, action.payload) | |
default: | |
return state | |
} | |
} | |
export default function useSortableTable( | |
data: TableDataItem[], | |
defaultSortKey: string | |
) { | |
const initialState: SortableTableState = { | |
sortedData: data, | |
sortKey: defaultSortKey, | |
sortDirection: 'asc', | |
} | |
const [state, dispatch] = useReducer(sortReducer, initialState) | |
const sortByKey = (key: string) => { | |
const direction = | |
key === state.sortKey && state.sortDirection === 'asc' ? 'desc' : 'asc' | |
dispatch({ type: ActionType.SORT, payload: { key, direction } }) | |
} | |
const toggleSelect = (id: string) => { | |
dispatch({ type: ActionType.TOGGLE_SELECT, payload: { id } }) | |
} | |
return { | |
...state, | |
sortByKey, | |
toggleSelect, | |
} | |
} |
Now that we have updated our useSortableTable
hook to include the toggleSelect
function, let's integrate it into our SortableTable
component:
// SortableTable.tsx | |
import useSortableTable, { TableDataItem } from './useSortableTable' | |
import styles from './SortableTable.module.css' | |
type TableComponentProps = { | |
data: TableDataItem[] | |
columns: string[] | |
onItemSelectChange?: (itemId: string, isChecked: boolean) => void | |
} | |
export default function SortableTable({ | |
data, | |
columns, | |
onItemSelectChange, | |
}: TableComponentProps) { | |
const { sortedData, sortKey, sortDirection, sortByKey, toggleSelect } = | |
useSortableTable(data, columns[0]) | |
// Event handlers should be kept in the component's scope | |
const handleToggleSelect = (itemId: string, isChecked: boolean) => { | |
toggleSelect(itemId) | |
if (typeof onItemSelectChange === 'function') { | |
onItemSelectChange(itemId, isChecked) | |
} | |
} | |
return ( | |
<table className={styles.sortableTable}> | |
<thead> | |
<tr> | |
<th>Select</th> | |
{columns.map((column) => ( | |
<th key={column} onClick={() => sortByKey(column)}> | |
{column} | |
{sortKey === column && ( | |
<span>{sortDirection === 'asc' ? '▲' : '▼'}</span> | |
)} | |
</th> | |
))} | |
</tr> | |
</thead> | |
<tbody> | |
{sortedData.map((item) => ( | |
<tr key={item.id} className={item.selected ? styles.selected : ''}> | |
<td> | |
<input | |
type="checkbox" | |
checked={item.selected} | |
onChange={(e) => handleToggleSelect(item.id, e.target.checked)} | |
/> | |
</td> | |
{columns.map((column) => ( | |
<td key={column}>{item[column]}</td> | |
))} | |
</tr> | |
))} | |
</tbody> | |
</table> | |
) | |
} |
In this updated SortableTable
component, we've added a new column for selection in the table. Each row contains a checkbox input that reflects the selected
property of the corresponding TableDataItem
. We use the toggleSelect
function from the useSortableTable
hook controller to toggle the selection state when the checkbox is clicked.
This new feature enhances the functionality of our sortable table component, allowing users to select or deselect individual table rows with ease.
Conclusion
React's custom hook controllers provide an effective approach to managing complex state interactions within applications. By encapsulating intricate logic within a centralized custom hook controller, developers can achieve cleaner and more maintainable codebases. This approach not only promotes better code organization but also facilitates testing and enhances reusability.
Furthermore, the flexibility and extensibility of custom hook controllers allow for seamless integration of new features, demonstrating their versatility in handling evolving application requirements. In summary, leveraging custom hook controllers empowers developers to build scalable and maintainable React applications by promoting code separation, facilitating testing, and enhancing reusability. Embracing this approach can lead to more efficient development workflows and robust applications in the long run.
Top comments (0)