Let’s say you have the following custom hook:
function useCount() {const [count, setCount] = useState(0)const increment = () => setCount((c) => c + 1)return { count, increment }}
Then, you have two components, one to display the count and another to increment it:
function DisplayCount() {const { count } = useCount()return <div>{count}</div>}function IncrementCount() {const { increment } = useCount()return <button onClick={increment}>Increment</button>}function App() {return (<div><DisplayCount /><IncrementCount /></div>)}
If you tried that you’d find that clicking the increment button doesn’t work.
That’s because each component is creating its own instance of the state in the
useCount
hook, so they’re not sharing the same state. In fact, even if you
call the hook multiple times in the same component, you’d still get different
instances of the state.
Often this is what you want. You want the isolation. But sometimes, you want to have that state be shared between the components.
Often you solve this problem by lifting state, but sometimes that’s not very practical because you’re many layers away from the components that need to share the state or perhaps there’s a router involved and you aren’t the one rendering the components and don’t have an easy way to pass props down.
Another situation where you would use this is if you wanted to implicitly share state between components so consumers of those components can use them without having to know about the state. This is a pattern known as “Compound Components” and we’ll learn more about that in the Compound Components exercise of the Advanced React Patterns workshop.
In these cases, you can use
context. React router
actually has a built-in feature that allows you to pass state into context and
access that value in child routes called
outletContext
and in my apps this is what I use most of the time. However, sometimes I do
still reach for context.
Here’s how you would use context to share state between components:
import { createContext, use } from 'react'type CountContextType = {count: numberincrement: () => void}const CountContext = createContext<CountContextType | null>(null)function CountProvider({ children }: { children: ReactNode }) {const [count, setCount] = useState(0)const increment = () => setCount((c) => c + 1)const value = { count, increment }return <CountContext value={value}>{children}</CountContext>}function useCount() {const context = use(CountContext)if (!context) {throw new Error('useCount must be used within a CountProvider')}return context}function DisplayCount() {const { count } = useCount()return <div>{count}</div>}function IncrementCount() {const { increment } = useCount()return <button onClick={increment}>Increment</button>}function App() {return (<CountProvider><div><DisplayCount /><IncrementCount /></div></CountProvider>)}
The CountProvider
component is a context provider. It’s a component that
handles the state and provides it to the components that need it. The useCount
hook is a custom hook that uses the use
hook to access the context. The
DisplayCount
and IncrementCount
components are the consumers of the context.
Whenever the value
prop of the context provider changes, all the components
that consume the context value will rerender to get those updates. And they will
all be referencing the same state.
Providers can be nested and the closest provider to the consuming component will
be the one that provides the value. If there is no provider, the default value
will be used. In this case, the default value is null
and we throw an error if
the hook is used without a provider. This is generally a good practice, but
there are use cases where you might want to provide a default value. You’ll know
when you need to do that, but most of the time you’ll want to require a context
provider.
🧝♂️ I was
query
and setSearchParams
as props. Instead I
just wanted to call useSearchParams
in each of the components that need them
(there’s only one URL afterall).Well that didn’t work. Turns out we now have multiple instances of the search
params state and multiple subscriptions to the popstate
event. That’s not
what we want at all. It’s busted right now. Can you fix this with context?
👨💼 No worries Kellie. We can fix this.
So we want to create a context provider that will provide the useSearchParams
hook to the rest of the app.
We’re going to take this in steps though. First we’ll create the context
provider, render it around the app, and then make a new useSearchParams
hook
that will use the context value.
Good luck! When you’re finished, the app should be working again!
🧝♂️ I was playing around with the app and I realized that we have a default value for our context provider. So I thought maybe we could
👨💼 Yeah, it’s because even though the searchParams
are shared, they’re not
updated when calling setSearchParams
. This is because the searchParams
object is no longer state. It’s just a plain object. So when we call
setSearchParams
, nothing rerenders (and even if it did, it wouldn’t have
the updated searchParams
).
So having a default in our context is pretty meaningless. So why don’t we
default it to null
and then in our useSearchParams()
hook we can throw an
error if the context is null
? Go ahead and give it a try.
When you’re done, the app should be working again.
import { createContext, useEffect, useState, use, useCallback } from 'react'import * as ReactDOM from 'react-dom/client'import {type BlogPost,generateGradient,getMatchingPosts,} from '#shared/blog-posts'import { setGlobalSearchParams } from '#shared/utils'type SearchParamsTuple = readonly [URLSearchParams,typeof setGlobalSearchParams,]const SearchParamsContext = createContext<SearchParamsTuple | null>(null)function SearchParamsProvider({ children }: { children: React.ReactNode }) {const [searchParams, setSearchParamsState] = useState(() => new URLSearchParams(window.location.search),)useEffect(() => {function updateSearchParams() {setSearchParamsState((prevParams) => {const newParams = new URLSearchParams(window.location.search)return prevParams.toString() === newParams.toString()? prevParams: newParams})}window.addEventListener('popstate', updateSearchParams)return () => window.removeEventListener('popstate', updateSearchParams)}, [])const setSearchParams = useCallback((...args: Parameters<typeof setGlobalSearchParams>) => {const searchParams = setGlobalSearchParams(...args)setSearchParamsState((prevParams) => {return prevParams.toString() === searchParams.toString()? prevParams: searchParams})return searchParams},[],)const searchParamsTuple = [searchParams, setSearchParams] as constreturn (<SearchParamsContext value={searchParamsTuple}>{children}</SearchParamsContext>)}export function useSearchParams() {const context = use(SearchParamsContext)if (!context) {throw new Error('useSearchParams must be used within a SearchParamsProvider',)}return context}const getQueryParam = (params: URLSearchParams) => params.get('query') ?? ''function App() {return (<SearchParamsProvider><div className="app"><Form /><MatchingPosts /></div></SearchParamsProvider>)}function Form() {const [searchParams, setSearchParams] = useSearchParams()const query = getQueryParam(searchParams)const words = query.split(' ').map((w) => w.trim())const dogChecked = words.includes('dog')const catChecked = words.includes('cat')const caterpillarChecked = words.includes('caterpillar')function handleCheck(tag: string, checked: boolean) {const newWords = checked ? [...words, tag] : words.filter((w) => w !== tag)setSearchParams({ query: newWords.filter(Boolean).join(' ').trim() },{ replace: true },)}return (<form onSubmit={(e) => e.preventDefault()}><div><label htmlFor="searchInput">Search:</label><inputid="searchInput"name="query"type="search"value={query}onChange={(e) =>setSearchParams({ query: e.currentTarget.value }, { replace: true })}/></div><div><label><inputtype="checkbox"checked={dogChecked}onChange={(e) => handleCheck('dog', e.currentTarget.checked)}/>{' '}🐶 dog</label><label><inputtype="checkbox"checked={catChecked}onChange={(e) => handleCheck('cat', e.currentTarget.checked)}/>{' '}🐱 cat</label><label><inputtype="checkbox"checked={caterpillarChecked}onChange={(e) =>handleCheck('caterpillar', e.currentTarget.checked)}/>{' '}🐛 caterpillar</label></div></form>)}function MatchingPosts() {const [searchParams] = useSearchParams()const query = getQueryParam(searchParams)const matchingPosts = getMatchingPosts(query)return (<ul className="post-list">{matchingPosts.map((post) => (<Card key={post.id} post={post} />))}</ul>)}function Card({ post }: { post: BlogPost }) {const [isFavorited, setIsFavorited] = useState(false)return (<li>{isFavorited ? (<buttonaria-label="Remove favorite"onClick={() => setIsFavorited(false)}>❤️</button>) : (<button aria-label="Add favorite" onClick={() => setIsFavorited(true)}>🤍</button>)}<divclassName="post-image"style={{ background: generateGradient(post.id) }}/><ahref={post.id}onClick={(event) => {event.preventDefault()alert(`Great! Let's go to ${post.id}!`)}}><h2>{post.title}</h2><p>{post.description}</p></a></li>)}const rootEl = document.createElement('div')document.body.append(rootEl)ReactDOM.createRoot(rootEl).render(<App />)
html,body {margin: 0;}.app {margin: 40px auto;max-width: 1024px;form {text-align: center;}}.post-list {list-style: none;padding: 0;display: flex;gap: 20px;flex-wrap: wrap;justify-content: center;li {position: relative;border-radius: 0.5rem;overflow: hidden;border: 1px solid #ddd;width: 320px;transition: transform 0.2s ease-in-out;a {text-decoration: none;color: unset;}&:hover,&:has(*:focus),&:has(*:active) {transform: translate(0px, -6px);}.post-image {display: block;width: 100%;height: 200px;}button {position: absolute;font-size: 1.5rem;top: 20px;right: 20px;background: transparent;border: none;outline: none;&:hover,&:focus,&:active {animation: pulse 1.5s infinite;}}a {padding: 10px 10px;display: flex;gap: 8px;flex-direction: column;h2 {margin: 0;font-size: 1.5rem;font-weight: bold;}p {margin: 0;font-size: 1rem;color: #666;}}}}@keyframes pulse {0% {transform: scale(1);}50% {transform: scale(1.3);}100% {transform: scale(1);}}
Quick Links
Legal Stuff
Social Media