With React, we have quite a few options when it comes to state management. One of the most obvious ones is using the local state. Unfortunately, it might lead to prop drilling. It happens when we pass data from one component through many layers. When some of the components along the way do not need the data but only help passing it around, it starts to feel like an issue.
Due to the above issue, the React community implemented various solutions. Redux is one of the most popular, and the Context API didn’t replace it. In fact, Redux uses context under the hood. In this article, we learn what the context is and how to use it with TypeScript and hooks.
Introducing the context
The React Context allows us to provide data through components. With it, we don’t need to pass them down manually through every level of components. We can also update the data from any child component. Doing so affects all the other places that use the same context.
Creating the context
The first step in creating our context is calling the createContext method.
1 2 3 4 5 |
interface Post { id: number; title: string; body: string; } |
1 2 3 4 5 6 7 8 9 |
export interface PostsContextData { posts: Post[]; } export const postsContextDefaultValue: PostsContextData = { posts: [] } export const PostsContext = createContext<PostsContextData>(postsContextDefaultValue); |
The next step is to provide the above context to our application. Every context that we create has a Provider that we need to use. Thanks to doing so, our components can subscribe to the context changes.
1 2 3 4 5 6 7 |
function App() { return ( <PostsContext.Provider value={postsContextDefaultValue} > <PostsList /> </PostsContext.Provider> ); } |
You might notice above that we provide the default value in two different places. When there is no Provider, React uses the value provided to the createContext function. It might come in handy when testing components without wrapping them with a Provider. We expand more on this concept further below.
Consuming the context
To consume the above context, we can use the useContext hook. We need to give it the context object created above. In return, we get the current context value.
We could also do the above with the use of PostsContext.Consumer
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
const PostsList = () => { const { posts } = useContext(PostsContext); return ( <div> { posts.map(post => ( <div key={post.id}> <h2>{post.title}</h2> <p>{post.body}</p> </div> )) } </div> ) } |
Under the hood, the useContext looks for the nearest PostsContext.Provider and gives us its value.
Using state in our context
One of the most common use-cases with the context is a situation in which we need to update it. To do so, we need to keep our values in an instance of a local state. To do so, we can use the useState hook. At first glance, it might look like a good idea to put this logic in our parent component.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
export interface PostsContextData { posts: Post[]; isLoading: boolean; fetchPosts: () => void; removePost: (postId: number) => void; } export const postsContextDefaultValue: PostsContextData = { posts: [], isLoading: false, fetchPosts: () => null, removePost: () => null } export const PostsContext = createContext<PostsContextData>(postsContextDefaultValue); |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
function App() { const [posts, setPosts] = useState<Post[]>([]); const [isLoading, setIsLoading] = useState(false); const fetchPosts = useCallback(() => { setIsLoading(true); fetch('https://jsonplaceholder.typicode.com/posts') .then(response => response.json()) .then((fetchedPosts) => { setPosts(fetchedPosts); }) .finally(() => { setIsLoading(false); }) }, [setPosts]); const removePost = useCallback((postId: number) => { setIsLoading(true); fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`, { method: 'DELETE' }) .then(() => { const newPosts = [...posts]; const removedPostIndex = newPosts.findIndex(post => post.id === postId); if (removedPostIndex > -1) { newPosts.splice(removedPostIndex, 1); } setPosts(newPosts); }) .finally(() => { setIsLoading(false); }) }, [setPosts, posts]); return ( <PostsContext.Provider value={{ posts, isLoading, fetchPosts, removePost }} > <PostsList /> </PostsContext.Provider> ); } |
The above is not the most graceful approach, unfortunately. Instead, I suggest creating a custom hook using the facade design pattern.
If you want to know more about the facade pattern, check out JavaScript design patterns #3. The Facade pattern and applying it to React Hooks
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
function usePostsContextValue(): PostsContextData { const [posts, setPosts] = useState<Post[]>([]); const [isLoading, setIsLoading] = useState(false); const fetchPosts = useCallback(() => { setIsLoading(true); fetch('https://jsonplaceholder.typicode.com/posts') .then(response => response.json()) .then((fetchedPosts) => { setPosts(fetchedPosts); }) .finally(() => { setIsLoading(false); }) }, [setPosts]); const removePost = useCallback((postId: number) => { setIsLoading(true); fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`, { method: 'DELETE' }) .then(() => { const newPosts = [...posts]; const removedPostIndex = newPosts.findIndex(post => post.id === postId); if (removedPostIndex > -1) { newPosts.splice(removedPostIndex, 1); } setPosts(newPosts); }) .finally(() => { setIsLoading(false); }) }, [setPosts, posts]); return { posts, isLoading, fetchPosts, removePost } } |
Now, we can use the above hook to clean up our App component.
1 2 3 4 5 6 7 8 9 |
function App() { const postsContextValue = usePostsContextValue(); return ( <PostsContext.Provider value={postsContextValue} > <PostsList /> </PostsContext.Provider> ); } |
Updating the context within the child components
Thanks to doing all of the above, we can start updating our context from child components. First, let’s write a hook for loading our posts.
1 2 3 4 5 6 7 |
function usePostsLoading() { const { fetchPosts } = useContext(PostsContext); useEffect(() => { fetchPosts(); }, [fetchPosts]) } |
Now, we can use that in our PostsList component.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
const PostsList = () => { const { posts, removePost } = useContext(PostsContext); usePostsLoading(); return ( <div> { posts.map(post => ( <div key={post.id} onClick={() => removePost(post.id)}> <h2>{post.title}</h2> <p>{post.body}</p> </div> )) } </div> ) } |
Further improvements
One of the most straightforward ways to improve the above code is not to use the arrow function in our PostsList template. Doing so causes our arrow function to be recreated on each render, which can hurt our performance. Let’s create a separate hook for dealing with this handler.
1 2 3 4 5 6 7 8 9 10 11 12 |
function usePostsListManagement() { const { removePost, posts } = useContext(PostsContext); const handlePostRemove = useCallback((postId: number) => () => { removePost(postId); }, [removePost]); return { handlePostRemove, posts } } |
Now, our handlePostRemove function returns another function. Thanks to that, by calling handlePostRemove(post.id), we create a removal handler for a particular post.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
const PostsList = () => { const { posts, handlePostRemove } = usePostsListManagement(); usePostsLoading(); return ( <div> { posts.map(post => ( <div key={post.id} onClick={handlePostRemove(post.id)}> <h2>{post.title}</h2> <p>{post.body}</p> </div> )) } </div> ) } |
Skipping the default value for the createContext function
Another interesting adjustment to our code is the one suggested by Kent C. Dodds in his article. Although providing the default value for the createContext function allows us to use the context without the provider, it might not be the best approach. Even though the documentation states that it can be useful for testing, we might prefer our tests to be more similar to the way our application works.
To skip the default values for the createContext function, we need to adjust our typing slightly.
1 2 3 4 5 6 7 8 |
export interface PostsContextData { posts: Post[]; isLoading: boolean; fetchPosts: () => void; removePost: (postId: number) => void; } export const PostsContext = createContext<PostsContextData | undefined>(undefined); |
1 2 3 4 5 6 7 |
function usePostsContext() { const postsContext = useContext(PostsContext); if (!postsContext) { throw new Error('usePostsContext must be used within the PostsContext.Provider'); } return postsContext; } |
Instead of calling useContext(PostsContext) every time, we use the usePostsContext hook, as suggested by Kent C. Dodds.
Memoizing the context value
We can use the useMemo hook to improve the performance of our application and get rid of some of the unnecessary rerenders. Let’s look into the official documentation:
All consumers that are descendants of a Provider will re-render whenever the Provider’s
value
prop changes.
To deal with this, let’s use the useMemo hook in our usePostsContextValue:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
function usePostsContextValue(): PostsContextData { const [posts, setPosts] = useState<Post[]>([]); const [isLoading, setIsLoading] = useState(false); const fetchPosts = useCallback(() => { setIsLoading(true); fetch('https://jsonplaceholder.typicode.com/posts') .then(response => response.json()) .then((fetchedPosts) => { setPosts(fetchedPosts); }) .finally(() => { setIsLoading(false); }) }, [setPosts]); const removePost = useCallback((postId: number) => { setIsLoading(true); fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`, { method: 'DELETE' }) .then(() => { const newPosts = [...posts]; const removedPostIndex = newPosts.findIndex(post => post.id === postId); if (removedPostIndex > -1) { newPosts.splice(removedPostIndex, 1); } setPosts(newPosts); }) .finally(() => { setIsLoading(false); }) }, [setPosts, posts]); return useMemo(() => ({ posts, isLoading, fetchPosts, removePost }), [ posts, isLoading, fetchPosts, removePost ]); } |
Summary
In this article, we’ve looked into how we can use React Context API with TypeScript. While it does not replace Redux, it still has use-cases. We’ve also dealt with a few issues, such as improving the performance by dealing with unnecessary arrow functions and memoizing the context value. To avoid providing the default value multiple times, we’ve created our custom hook for getting the current value of the context.
What do you think about the solution provided above? Would you implement some additional improvements?
In section Further improvements theres a mistake.
const PostsList = () => {
const { posts, handlePostRemove } = usePostsListManagement();
usePostsLoading();
return (
<div>
{
posts.map(post => (
<div key={post.id} onClick={handlePostRemove(post.id)}>
<h2>{post.title}</h2>
<p>{post.body}</p>
</div>
))
}
</div>
)
}
In that line
<div key={post.id} onClick={handlePostRemove(post.id)}>
You actually execute
handlePostRemove
not on click, but at render. what You are interested in is most likelyconst postId = post.id;
const handleClick = useCallback(() => handlePostRemove(postId), [handlePostRemove, postId])
.
<div key={post.id} onClick={handleClick}>
He used curry function, so handlePostRemove has already returned callback
This is the one im looking for. So clear . Thanks a lot🙏 👍
Thank you, finally example that worked as intended!
my mind blows
Good post and explanation.
I am trying do add “Posts” by user action (in my case the “Posts” are uploaded files)
so running the fetch in .useEffect(… is not an option
how can I add Posts at the function App() in a function?
Should I go back to the step before “Now, we can use the above hook to clean up our App component.” ?
Or should I add the code to add posts in the “PostsList” ?