React hooks, refs and tables
In this lesson, we will look at a few more hooks, some of which are needed purely for optimization. We will discuss the concepts of useRef
and forwardRef,
which may be necessary when creating your own component library. We will discuss the possibility of having a global state that all components can access. Finally, we'll look at creating and working with tables, again using the external tanstack-table
library.
useCallback, useMemo, memo
useCallback
useCallback
is a hook that returns a memoized version of the callback function. This means that if a value changes, the function is not recreated. This can be useful if the function is passed to another component that would otherwise be re-rendered on each render. As discussed in the last lesson, we know we have types in JS that are passed by reference, not by value. These include objects and arrays, among others. If we define an arrow function inside a component, it is also an object.
So, if we pass this function to another component, this component will be re-rendered every time a new function is passed. This may be undesirable if this component contains more complex logic or the passed function is part of another dependency array.
function Child({ onChange }: { onChange: () => void }) {
console.log('rendering child');
return (
<div>
<button onClick={onChange}>Change</button>
</div>
);
}>
function Parent() {
console.log('rendering parent');
const [value, setValue] = useState('');
const [childClicksCount, setChildClicksCount] = useState(0);
const onChange = () => {
console.log('Calling onChange');
setChildClicksCount(childClicksCount + 1);
};
return (
<div>
<input
onChange={e => {
setValue(e.target.value);
}}
value={value}
/>
<Child onChange={onChange} />
<div>Child clicks count: {childClicksCount}</div>
</div>
);
}
In the code above, every time the value
in the Parent
component changes,
the Parent
is re-rendered because its state has changed. However, it also
renders Child
since its onChange
property has changed. Better said,
onChange
has not changed, but it is a new instance of a function that is
created when the Parent
component is re-rendered and then passed to the
Child
component.
The onChange
function does not need to be rendered every time the value
changes, but only if the childClickCount
state has changed. For a better illustration, we can add a "heavy" operation to the Child
component that causes rendering every time the value
changes to be blocked by rendering the Child
component. For testing purposes, we can represent the heavy operation as follows, for example:
for (let i = 0; i < 1e9; i++) {
// Heavy computation
}
When typing in the text box, we see that the Child
component has to re-render itself every time a letter is typed, which blocks the whole UI and is unpleasant for the user. We would avoid this behavior if we could prevent the Child
component from re-rendering.
function Child({ onChange }: { onChange: () => void }) {
console.log('rendering child');
heavyOperation();
return (
<div>
<button onClick={onChange}>Change</button>
</div>
);
}
function Parent() {
console.log('rendering parent');
const [value, setValue] = useState('');
const [childClicksCount, setChildClicksCount] = useState(0);
// Only re-create 'onChange' when 'childClicksCount' changes
const onChange = useCallback(() => {
console.log('Calling onChange');
setChildClicksCount(childClicksCount + 1);
}, [childClicksCount]);
return (
<div>
<input
onChange={e => {
setValue(e.target.value);
}}
value={value}
/>
<Child onChange={onChange} />
<div>Child clicks count: {childClicksCount}</div>
</div>
);
}
In this case, we have already ensured that the onChange
function will not be
recreated every time the value
is changed, so we send a stable reference to
the same function to the Child
component.
The way React works is that if a Parent
needs to be rerendered, all its children are rerendered, regardless of whether their properties have changed. We can prevent this behavior by using React.memo
.
memo
React.memo
is a function that returns a memoized version of the component. This means that if the properties of the component are not changed, the component will not be rendered again.
Overusing memo
can cause performance problems rather than prevent them! At the same time, using memo
the right way can be non-trivial in many cases.
function Child({ onChange }: { onChange: () => void }) {
console.log('rendering child');
heavyOperation();
return (
<div>
<button onClick={onChange}>Change</button>
</div>
);
}
const MemoizedChild = memo(Child);
function Parent() {
console.log('rendering parent');
const [value, setValue] = useState('');
const [childClicksCount, setChildClicksCount] = useState(0);
const onChange = useCallback(() => {
console.log('Calling onChange');
setChildClicksCount(childClicksCount + 1);
}, [childClicksCount]);
return (
<div>
<input
onChange={e => {
setValue(e.target.value);
}}
value={value}
/>
<MemoizedChild onChange={onChange} />
<div>Child clicks count: {childClicksCount}</div>
</div>
);
}
In this case, we have already achieved what we asked for. The Child
component is not rendered every time the value
changes, but only if the
childClickCount
state has changed.
Generally, it is better to use React.memo
only when there is no other way to prevent re-rendering the component. In our case, there is another option, namely, using the composability of React.
function Child({ onChange }: { onChange: () => void }) {
console.log('Child rendering');
// ...
}
function ChildClicksCounter() {
console.log('Child clicks counter rendering');
const [childClicksCount, setChildClicksCount] = useState(0);
return (
<>
<Child onChange={() => setChildClicksCount(childClicksCount + 1)} />
<div>Child clicks count: {childClicksCount}</div>
</>
);
}
function Parent({ children }: PropsWithChildren<object>) {
console.log('rendering parent');
const [value, setValue] = useState('');
return (
<div>
<input onChange={e => setValue(e.target.value)} value={value} />
{children}
</div>
);
}
export default function Home() {
return (
<Parent>
<ChildClicksCounter />
</Parent>
);
}
Alternatives to React.memo
may be general techniques for "moving state down" or "lifting content up." In this case, we have dropped the childClickCount
state inside the Parent
component and created a new ChildClicksCounter
component. This component is sent to the Parent
component as children. This way, if the value
state changes in the parent, the children
property will not be re-rendered since the children
remain the same.
useMemo
useMemo
works on a similar principle as useCallback
. It returns a memoized value, typically an array or object that is created by some more complex operation. An example might be a filtered array or an object created based on another object, and this transformation is a complex operation.
function PersonTable({ data, filter }: { data: string[]; filter: string }) {
console.log('rendering PersonTable');
const filteredData = useMemo(() => {
console.log('computing filteredData');
// Simulate heavy computation
heavyOperation();
return data.filter(person => person.includes(filter));
}, [data, filter]);
const [query, setQuery] = useState('');
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<table>
<tbody>
{filteredData.map(person => (
<tr key={person}>
<td>{person}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
In this case, we store the filtered data in useMemo
so that we don't have to
filter the data again every time we change query.
This way we achieve that
filteredData
is not created every time the component is rendered, but only
when either data
or filter
is changed.
If we had a separate component for rendering the table, the same rules as for useCallback
would apply, i.e. we would have to memoize the table component using React.memo
.
function PersonTable({ data }: { data: string[] }) {
console.log('rendering PersonTable');
return <table>{/* ... */}</table>;
}
const MemoizedPersonTable = memo(PersonTable);
function PersonModule({ data, filter }: { data: string[]; filter: string }) {
console.log('rendering PersonModule');
const filteredData = useMemo(() => {
console.log('computing filteredData');
// Simulate heavy computation
heavyOperation();
return data.filter(person => person.includes(filter));
}, [data, filter]);
const [query, setQuery] = useState('');
return (
<div>
<input /* ... */ />
<MemoizedPersonTable data={filteredData} />
</div>
);
}
This way, we achieved that if the table were a more complex component, it
would only be re-rendered if the data
property changed, which is a memoized
component that changes if the incoming data
or filter
changes.
Unfortunately, in this case, it is very easy to make a very small change and break this behavior.
function PersonTable({ data }: { data: string[] }) {
console.log('rendering PersonTable');
return <table>{/* ... */}</table>;
}
const MemoizedPersonTable = memo(PersonTable);
function PersonModule({ data, filter }: { data: string[]; filter: string }) {
console.log('rendering PersonModule');
const filteredData = useMemo(() => {
console.log('computing filteredData');
// Simulate heavy computation
heavyOperation();
return data?.filter(person => person.includes(filter));
}, [data, filter]);
const [query, setQuery] = useState('');
return (
<div>
<input /* ... */ />
<MemoizedPersonTable data={filteredData ?? []} />
</div>
);
}
We changed a seemingly small thing, namely that the PersonModule
component
can receive undefined
data, in which case we send an empty array []
to the
PersonTable
. However, this array is no longer memoized and causes the
memoization with React.memo
to stop working.
function PersonTable({ data }: { data: string[] }) {
console.log('rendering PersonTable');
return <table>{/* ... */}</table>;
}
const MemoizedPersonTable = memo(PersonTable);
function PersonModule({ data }: { data: string[] }) {
console.log('rendering PersonModule');
return (
<div>
{/* We can't be sure here, if 'data' are memoized in parent component */}
<MemoizedPersonTable data={data} />
</div>
);
}
export default function Home() {
const [query, setQuery] = useState('');
return (
<main className="container py-10">
<input /* ... */ />
{/* Passing new instance of array every render, causing
re-rendering of PersonTable everytime 'query' change */}
<PersonModule
data={
[
/* ... */
]
}
/>
</main>
);
}
At the same time, if a component receives some data via props
and sends this
data to a component that needs to be memoized, it cannot be sure whether the
data in props
is memoized in the parent component or not.
For more details and examples, we recommend reading blog post by Dominik Dorfmeister.
useRef, forwardRef
useRef
The useRef
is one of the hooks in React that allows you to access DOM element references or store arbitrary values across the lifecycle of a component without causing it to re-render. useRef is useful in several different scenarios:
Access to DOM elements
In some cases it is necessary to be able to manipulate DOM elements directly, for example <input>
or <div>
. In React, this is done using the ref
attribute, which is passed to the component. For example, if we wanted to set the focus to input when the component is loaded, we could do this:
function App() {
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} />;
}
Storage of values
The useRef
can also be used to store values. At the same time, it is not necessary to reflect the change of this value in the resulting JSX. This applies for example to the <input>
tag, where we can store the value of input
in ref
and retrieve this value from ref.current
when we submite the form.
function App() {
const nameRef = useRef<HTMLInputElement>(null);
return (
<form
onSubmit={e => {
e.preventDefault();
const name = nameRef.current?.value;
console.log(name);
}}
>
<input ref={nameRef} />
<button type="submit">Submit</button>
</form>
);
}
This may be preferable to using useState
to maintain the current value of
the input
tag, since changing the ref
value does not cause the component
to re-render.
The ref
can also store less common values, such as an interval of type Timer
that can, for example, perform an action every second.
function TimerComponent() {
const intervalRef = useRef();
useEffect(() => {
intervalRef.current = setInterval(() => {
console.log('Timer tick');
}, 1000);
return () => clearInterval(intervalRef.current);
}, []);
return null;
}
forwardRef
forwardRef
is a function that allows you to pass ref
from a parent component to a child component. This can be useful when we want to be able to access a DOM element that is created in a child component from the parent component.
const FancyButton = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<object>>(
({ children, ...props }, ref) => {
return (
<button ref={ref} {...props}>
{children}
</button>
);
}
);
function Home() {
const ref = useRef<HTMLButtonElement>();
return (
<>
<button
onClick={() => {
console.log('Regular button clicked!');
ref.current?.click();
}}
>
Regular button
</button>
<FancyButton
ref={ref}
onClick={() => {
console.log('Fancy button clicked!');
}}
>
Fancy button
</FancyButton>
</>
);
}
In the Home
component we define a ref
, which we then send to the
FancyButton
component. This allows us to control the FancyButton
in the
Home
component using its reference and, for example, trigger its click.
In combination with some component libraries, it may be necessary to use forwarRef for the components to work properly. An example is Radix UI, a Dialog component. When using this library and component, Radix provides us with a set of predefined components that need to be put together.
import * as Dialog from 'radix-ui/react-dialog';
export default () => (
<Dialog.Root>
<Dialog.Trigger />
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title />
<Dialog.Description />
<Dialog.Close />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
The DialogTrigger
is configured by the library so that event handlers are defined on it to open the dialog. We only need to provide this component's children,
and this Trigger will automatically open the dialog. The problem occurs when we want to use some custom component as Trigger.
function MyDialog() {
return (
<Dialog.Root>
<Dialog.Trigger>Dialog trigger</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>Dialog content</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
In this case the Trigger
works fine, since we are not using a custom
component for the button/trigger.
function MyDialog() {
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<button>Dialog trigger</button>
</Dialog.Trigger>
...
</Dialog.Root>
);
}
We can use custom components like Trigger
, which is a relatively common use case because our application probably has some Button
component defined that is used in multiple places in the application. In this case, we are just using a plain <button>
tag and in this case everything works as it should.
function Button({ children }: PropsWithChildren) {
return <button>{children}</button>;
}
function MyDialog() {
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<Button>Dialog trigger</Button>
</Dialog.Trigger>
...
</Dialog.Root>
);
}
In this case the trigger dialog will not work. We do send a button as children
for Dialog.Trigger
, but as a custom component that does not pass a ref
to the DOM <button>
element. To fix this, we need to wrap the Button
component with a forwardRef
and pass the ref
to the <button>
element.
const Button = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<object>>(
function Button({ children, ...props }, ref) {
return (
<button ref={ref} {...props}>
{children}
</button>
);
}
);
function MyDialog() {
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<Button>Dialog trigger</Button>
</Dialog.Trigger>
...
</Dialog.Root>
);
}
Context
The Context
is used to store values that are global for the scope we define. This scope can be, for example, the whole application or just some part of the application. This hook is useful when we need to pass a value to several components that are not in a direct parent relationship. This avoids "prop drilling," where we would have to pass the value through several components that don't actually need it.
To create a context, we use the createContext
function, which returns an object with two properties - Provider
and Consumer.
The Provider
is the component that wraps all the components with access to the value we want to share. The Consumer
is typically not used since we use the useContext
hook to access the value that the Provider shares.
type UserContextType = {
name: string;
age: number;
};
const UserContext = createContext<UserContextType | null>(null);
For each context, we define its type, which is actually the value we want to share. This type can contain any values, just like the props of a component.
We use the created Context to create a Provider that wraps all components that have access to the value. This Provider can wrap any number of components. If it is a global application state, it can be directly in the root component of the application. If it is a Context that maintains information about, for example, a form, this Context will wrap only components that relate to that form.
type UserContextType = {
name: string;
age: number;
};
const UserContext = createContext<UserContextType | null>(null);
function UserContextProvider({ children }: PropsWithChildren) {
const [user, setUser] = useState({ name: 'John', age: 25 });
return <UserContext.Provider value={user}>{children}</UserContext.Provider>;
}
Typically, a new component is created as Provider
, which takes care of what value is sent to Context.Provider
. In this case, we create a user
state, which is then sent to the UserContext.Provider
.
Last, we miss the ability to access the user
value from components that are inside the UserContextProvider
. We do this by using the useContext
hook, which returns a value that is sent to UserContext.Provider
.
type UserContextType = {
name: string;
age: number;
};
const UserContext = createContext<UserContextType | null>(null);
function UserContextProvider({ children }: PropsWithChildren) {
const [user, setUser] = useState({ name: 'John', age: 25 });
return <UserContext.Provider value={user}>{children}</UserContext.Provider>;
}
function UserDetails() {
const user = useContext(UserContext);
return (
<div>
<h2>User Details</h2>
<p>Name: {user?.name}</p>
<p>Age: {user?.age}</p>
</div>
);
}
export default function Home() {
return (
<UserContextProvider>
<UserDetails />
</UserContextProvider>
);
}
By using the useContext
hook, which takes UserContext
as its parameter, we get the user
value and can work with it further. The problem is that the value of the context is defined as UserContextType | null
because of defining createContext
with the initial value null
.
This is because if we tried to access UserContext
from a component that is not wrapped by Provider, we would get null
. On the other hand, from a DX perspective, it would be better for us to get the UserContextType
from components that are wrapped by Provider and not have to worry about the null
value.
type UserContextType = {
name: string;
age: number;
};
const UserContext = createContext<UserContextType | null>(null);
function useUserContext() {
const user = useContext(UserContext);
if (!user) {
throw new Error('useUserContext must be used within a UserContextProvider');
}
return user;
}
function UserContextProvider({ children }: PropsWithChildren) {
const [user, setUser] = useState({ name: 'John', age: 25 });
return <UserContext.Provider value={user}>{children}</UserContext.Provider>;
}
function UserDetails() {
const user = useUserContext();
return (
<div>
<h2>User Details</h2>
<p>Name: {user.name}</p>
<p>Age: {user.age}</p>
</div>
);
}
export default function Home() {
return (
<>
<UserContextProvider>
<UserDetails />
</UserContextProvider>
<UserDetails />
</>
);
}
In this case the application will throw an error, because we use one instance
of the UserDetails
component outside the UserContextProvider
. But at the
same time, we are already using a custom useUserContext
hook in the
UserDetails
component, which ensures that the component will throw an error
if it is not wrapped by UserContextProvider
and also returns the
UserContextType
type, not UserContextType | null
.
An important caveat for using Context is that components that get a context value via useContext
are re-rendered whenever the context value changes. This can be undesirable if it is a more complex component that should only re-render if some other value changes.
function UserPage() {
const user = useUserContext();
return (
<div>
<h1>User Page</h1>
<span>{user.name}</span>
<span>{user.age}</span>
<HeavyComponent />
</div>
);
}
In this case, the context is not well placed, because if user
changes very
often, it would have to re-render HeavyComponent
frequently, which is not
desirable, since HeavyComponent
does not need the user
value.
function UserDetails() {
const user = useUserContext();
return (
<>
<span>Name: {user.name}</span>
<span>Age: {user.age}</span>
</>
);
}
function UserPage() {
return (
<div>
<h1>User Page</h1>
<UserDetails />
<HeavyComponent />
</div>
);
}
In this case, we have optimized the rendering by ensuring that if the user
in the context changes frequently, the HeavyComponent
component will not be
re-rendered since it does not depend on the user
value at all.
Use cases, when to use context
A typical use of context is to define the global state of a React application. This can be related to, for example, the state of a user, whether a user is logged in. Application state can also define the theme, language, or text translations. These settings are typically immutable, being defined once at the beginning. As for the theme, it is possible to provide an option to set light/dark mode, which will change the appearance of the whole application.
An example of a state that is not available to the whole application is a context that maintains the state of the form. This state is only available within the form and its children. This state can contain information about whether the form is valid or not and the values of individual inputs. Inputs or other form components have the ability to set the state of the form directly without having to pass the value through the props.
A similar use case is a table implementation. It can have a state regarding sorting, pagination, or filtering. If we have a context that maintains the state of the table, we can have different components that can manipulate that state without having to pass the value through the props. This is just about setting the filter value, sorting, or pagination.
The Tanstack Query library also works on the principle of maintaining data in the context, not only for getting data from the API or managing any async operation. The state of this operation is further available via hooks for all components, which are wrapped by QueryClientProvider.
We will learn more about this library in the next lesson.
Planned changes to React 19
On February 15, 2024, React released an article about planned changes to React in the next major release, which will be React 19. In this article, we can read about upcoming changes to some of the hooks and features we covered in this lesson. The full blog post can be found here
useContext(Context) -> use(Context), Context.Provider -> Context
There is a change coming in React 19 to simplify working with context. Currently, if we want to get a value from a context, we have to use the useContext
hook, which takes Context
as a parameter. This hook is to be simplified to use(Context)
. At the same time, a change is in the works to allow us to use Context
as Provider
so we don't have to use Context.Provider
.
useMemo / useCallback / React.memo
There is going to be a change in React 19 where you will not need to use useMemo
, useCallback
and React.memo
. This change is intended to simplify working with React while avoiding potential errors that can occur when using these hooks. In this lesson, we've shown a few examples of how it's easy to make a mistake that causes memoization to not work the way we want it to.
The biggest update within React 19 concerns the React Compiler. Up until now, React only serves as a library that doesn't care about how you write the code and doesn't do any optimizations on it. That will change with the arrival of the React Compiler, which should take care of what needs to be memized, when a component needs to be rebuilt, etc. Manual memoization was always just a compromise that React wasn't happy with and worked on a different solution because of that.
React.lazy
In the current version of React we have the possibility to specify which component is not loaded immediately when the application is loaded, using React.lazy. This feature is useful to use if we are initially rendering only part of the components that are contained in a screen component to the screen. Subsequently, based on some state, other components are conditionally rendered. These are just convenient to wrap in React.lazy, as they are not loaded immediately, but only when they are needed.
As with memoization, this is an optimization that we as developers control. In the future, the React Compiler could decide for us which components are needed immediately and which can be retrieved later to optimize the loading of the application.
Tanstack Table
In a large number of applications it is necessary to display a list of several entities, when their best display is in a table. This table can be more complex, containing for example filtering, sorting, or pagination options. Creating such a table can be challenging, and care must be taken to ensure that it is as optimized as possible, as it can contain large amounts of data. At the same time, we have the options of working with the data we display in the table only on the client, including the possibility of filtering, etc., or we can get the data from the API and call the API again when filtering to get new data.
Tanstack table is a Headless UI library that provides no components as far as rendering the table is concerned, but provides all the functionality to work with the table. Compared to a component library that provides a complete component that takes care of the entire table, this library is useful for creating a custom component that has exactly the functionality we need and looks exactly the way we want it to look.
Creating first table
After we install the library, we can create the table using the useReactTable
hook, which returns an object with all the functionality we need to work with the table. This hook accepts options
where at least 3 properties are required - columns
, data
and getCoreRowModel
.
data
The data
is an array that contains the data we want to display in the table. This data is typically retrieved from the API, but can be static. It is important that the field reference is stable, i.e. it does not change with every change in the state of the table. This can be achieved, for example, by storing the data in useMemo
, useState
or the data will be a static field that will not change.
type User = {
id: string;
name: string;
age: number;
};
const data: User[] = [
{
id: '1',
name: 'John',
age: 25
},
{
id: '2',
name: 'Jane',
age: 30
}
];
columns
columns
is an array that contains the definition of the table columns. Each column has several properties that define its behavior. The accessorKey
is a core property that defines how the value will be retrieved from the data that is displayed in the table. If we take the user field defined above as an example, we can define accessorKey
as name
, id
or age
. This way we are saying that the value that will be displayed in the table will be the value that is in the name
, id
or age
property.
Commonly defined properties include header
and cell
. These tell how the column header and the values in that column will be displayed, respectively.
const columns: ColumnDef<User, string>[] = [
{
accessorKey: 'id',
header: 'ID',
cell: cell => <div>{cell.getValue()}</div>
},
{
accessorKey: 'name',
header: 'Name',
cell: cell => <div>{cell.getValue()}</div>
},
{
accessorKey: 'age',
header: 'Age',
cell: cell => <div>{cell.getValue()}</div>
}
];
getCoreRowModel
This required option is a factory for a function that computes and returns the core row model for the table. It is called once per table and should return a new function which will calculate and return the row model for the table. Usually, you don't need to implement it yourself, but you can use the getCoreRowModel
function, which is provided by the library.
import { getCoreRowModel } from '@tanstack/react-table';
function Table() {
const table = useReactTable({
columns,
data,
getCoreRowModel: getCoreRowModel(),
});
return (
...
)
}
Now, we have the default setup of our table. A good starting point is to pick basic table implementation provided by the library here. Here's a basic example of the data and columns we are working with:
type User = {
id: string;
name: string;
age: number;
};
const columns: ColumnDef<User, string>[] = [
{
accessorKey: 'id',
header: 'ID',
cell: cell => <div>{cell.getValue()}</div>
},
{
accessorKey: 'name',
header: 'Name',
cell: cell => <div>{cell.getValue()}</div>
},
{
accessorKey: 'age',
header: 'Age',
cell: cell => <div>{cell.getValue()}</div>
}
];
const data: User[] = [
{
id: '1',
name: 'John',
age: 25
},
{
id: '2',
name: 'Jane',
age: 30
}
];
function Table() {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel()
});
return (
<table>
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id}>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
The flexRender
function from the library is used to render the cell and
header. This is the recommended way to render header and cell if you define
custom components in columns
.
Table context
In case we want to be able to filter, sort or paginate data, it is advisable to create a Context that will provide a table instance created by the useReactTable
hook. Through table
, child components can retrieve information about a column and perform filtering or other operations on it.
Filtering table
We have two options when it comes to filtering a table. The first option is to leave the filtering to the library and not "check" the value that the user has entered, but let the library take care of everything. This may be useful for the case of filtering data on the client.
const columns: ColumnDef<User, string>[] = [
...
{
id: 'name',
accessorKey: 'name',
header: 'Name',
cell: (cell) => <div>{cell.getValue()}</div>,
enableColumnFilter: true,
},
...
];
type TableContextType = Table<User>;
const TableContext = createContext<TableContextType | null>(null);
function useTableContext() {
const table = useContext(TableContext);
if (!table) {
throw new Error('useTableContext must be used within a TableContext');
}
return table;
}
function NameFilter() {
const table = useTableContext();
const column = table.getColumn('name');
return (
<input
onChange={(e) => column?.setFilterValue(e.target.value)}
value={(column?.getFilterValue() ?? '') as string}
/>
);
}
function UserTable() {
const table = useReactTable({
columns,
data,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
});
return (
<TableContext.Provider value={table}>
<table>
...
</table>
<NameFilter />
</TableContext.Provider>
);
}
Using the getFilterValue
and setFilterValue
methods we can create a
component for filtering data in the table. We need to specify in columns
that the given column allows filtering and assign an id
by which to retrieve
the column from the table. Finally, we need to assign getFilteredRowModel
to
the useReactTable
hook, which ensures that the data will be filtered.
The other option is to keep the filter state in a separate state. This has the advantage that we have full control over how the data will be filtered. It also allows us to filter in such a way that every time we change the filter, we call the API and get new data.
function UserTable() {
const [filterState, setFilterState] = useState<ColumnFiltersState>([]);
const tableData = useMemo(() => {
const nameFilterValue = filterState.find((filter) => filter.id === 'name')
?.value as string;
if (nameFilterValue) {
return data.filter((user) => user.name.includes(nameFilterValue));
}
return data;
}, [filterState]);
const table = useReactTable({
columns,
data: tableData,
getCoreRowModel: getCoreRowModel(),
onColumnFiltersChange: setFilterState,
state: {
columnFilters: filterState,
},
});
return (
<TableContext.Provider value={table}>
<table>
...
</table>
<NameFilter />
</TableContext.Provider>
);
In this case we keep the filter state in filterState
and call
setFilterState
whenever the filter state changes. The useMemo
responds to
a change in filter state by ensuring that the data is filtered.
This works similarly with sorting, hiding some columns or pagination. See the library documentation for more information.
Next lecture
In the next lesson, we'll come back to Context a bit, as knowing it is key to explaining other things. We will describe in detail the asynchronous behavior in javascript and then in React. We'll discuss the Tanstack Query library for working with asynchronous operations, explain the importance of runtime data management, and finally describe how to work with forms using the React Hook Form library.
Assignment
This week, implement a user management application that dynamically updates a table when adding users via a dialog-based form. Optimize performance using memoization, enable sorting for all table columns. Integrate a dialog component from the shadcn library for form submissions