Skip to content
On this page

State with the Context API

I was recently chatting with a friend of mine who is changing career paths and working towards becoming a frontend developer. The course he is doing focuses primarily on Reactjs, and has only taught him about sharing state across his app using React Redux (looking at you Codecademy). While Redux is fine and has some great features, there is a lot of work involved to set up your store and slices that can feel like overkill for smaller apps.

This post is NOT a rant about why you should not use Redux, rather, this presents an alternative approach for sharing state with the built-in Context API.

The examples below assume a general familiarity with TypeScript and React.

Setup

If you're coming from React Redux or Vuex, think of a context as a store. First lets define what data and/or functions we're going to share across the app. We'll just keep this super simple for the sake of brevity. Also, you can organize your files any way you like but this will pretend we're placing everything in src/contexts/MyContext/

ts
// types.ts
export type MyContextData = {
  items: string[]
  fetchItems: (items: string[]) => void
}

Next we'll create the context and an abstraction around the useContext hook to help reduce the number of imports around the app.

ts
// MyContext.ts
import React, { createContext, useContext } from 'react'
import { MyContextData } from './types'

export const MyContext = createContext({} as MyContextData)
export const useMyContext = () => useContext(MyContext)

Now we'll set up the Provider component that provides the data and/or functions defined in our context. Similar to a Redux provider, it will wrap consuming components.

tsx
// MyContextProvider.tsx
import React, { useState } from 'react'
import { MyContext } from './MyContext.ts'

export const MyContextProvider = ({ children }: { children: ReactNode }) => {
  const [items, setItems] = useState<string[]>([])
  const fetchItems = async () => {
    const val = await fetch('http://my-api.com/items').then((res) => res.json())
    setItems(val)
  }
  return (
    <MyContext.Provider value={{ items, fetchItems }}>
      {children}
    </MyContext.Provider>
  )
}

Remember that child components re-render if the parent does. A context provider is just a React component. It is best to only wrap components that will be consuming the context to avoid unnecessary re-rendering.

tsx
// App.tsx
import React from 'react'
import { MyList } from 'components/MyList'
import { MyContextProvider } from 'contexts/MyContext'

const App = () => {
  return (
    <MyContextProvider>
      <MyList />
    </MyContextProvider>
  )
}

export default App

Accessing Data

Now we can consume the context with our hook.

tsx
// MyList.tsx
export const MyList = () => {
  const { items, fetchItems } = useMyContext()

  useLayoutEffect(() => {
    ;(async () => {
      await fetchItems()
    })()
  }, [])

  if (items.length === 0) {
    return <div id={'empty'}>empty</div>
  }

  return (
    <ul id={'list'}>
      {items.map((el) => (
        <li>{el}</li>
      ))}
    </ul>
  )
}

Unit Tests

Testing is an important part of any production app. We can easily create a mock context to ensure that our components are displaying elements as we expect them to.

tsx
// __mocks__/mockContext.ts
export const mockContext: MyContextData = {
  items: [],
  fetchItems: jest.fn(),
}
tsx
// MyList.spec.tsx
import { render, screen } from '@testing-library/react'
import { mockContext } from './__mocks__/mockContext'

const renderMyList = (ctx = mockContext) =>
  render(
    <MyContext.Provider value={ctx}>
      <MyList />
    </MyContext.Provider>,
  )

describe('MyList.tsx', () => {
  beforeEach(() => {
    jest.clearAllMocks()
  })
  it('should show empty text', () => {
    renderMyList()
    expect(screen.getByTestId('empty')).toBeInTheDocument()
    expect(screen.queryByTestId('list')).not.toBeInTheDocument()
    expect(mockContext.fetchItems).toHaveBeenCalledTimes(1)
  })

  it('should show list', () => {
    renderMyList({ ...mockContext, items: mockItems })
    expect(screen.getByTestId('list')).toBeInTheDocument()
    expect(screen.queryByTestId('empty')).not.toBeInTheDocument()
    expect(mockContext.fetchItems).toHaveBeenCalledTimes(1)
  })
})

Conclusion

In summary, the React Context API provides a great way to share data at any level in the component tree without prop-drilling or need to additional dependencies.