article

Redux 4 + TypeScript 2.9: A type-safe approach

An updated version of my type-safe guide to Redux, now compatible with Redux 4 + TypeScript 2.9.

This post is also published on:

Even when the JavaScript community is slowly growing mixed opinions about it, I couldn’t help but continue using Redux. Its patterns on immutable state management has become all too familiar to us, and is especially useful when building large apps. Its TypeScript support is exceptional too, with much-needed improvements to its type declarations arriving in Redux 4.

I wrote a guide on it a few months ago, and it has received some amount of attention. The feedback has helped me improve beyond what I normally would’ve done, and I couldn’t thank you enough for that.

In spirit of that, I finally took the time to update said guide based on the feedbacks I’ve received, making everything up to date with the latest version of React, Redux, and TypeScript, as well as introducing some neat new tricks.

Note that the following guide is tested on:

  • react@^16.4.0
  • redux@^4.0.0
  • typescript@~2.9.2

What we’re building

To demonstrate this post, we’re going to build a simple app. We’re going to create a website which pulls data from the OpenDota API, and display information about certain heroes and professional teams. This will also demonstrate how to structure your stores for each feature/module in a Redux-enabled app.

TL;DR

If you want to jump straight to the examples, I’ve also published a sample project in GitHub, based on the feedback from my previous post. Click here to go there.


Directory structure

I’ll level with you, one of the hardest steps in getting started with working on React + Redux for me is figuring out how to structure your project. There’s really no de facto way to do this, but it’s still important to get this right so to not cause further distractions down the road. Here’s how I normally do it.

Use a dedicated store/ directory

A lot of the guides/projects out there structure their store separately inside a root actions/ and reducers/ directory, to mimic the patterns in Redux architecture.

(Note that the following directory trees assume that your code is placed inside a src/ directory.)

.
|-- actions
|   |-- chat.ts
|   |-- index.ts
|   `-- layout.ts
|-- components
|   |-- Footer.tsx
|   `-- Header.tsx
|-- containers
|   `-- ChatWindow.tsx
|-- reducers
|   |-- chat.ts
|   |-- index.ts
|   `-- layout.ts
|-- ...
|-- index.tsx
`-- types.d.ts

However, I personally find this to be distracting. When your codebase grows larger, you would end up scattering code which shares the same context across a great length of the directory tree, which wouldn’t be intuitive for newcomers who wanted to take a quick glance at your code. Therefore, roughly following the Redux pattern in this case is less advantageous in terms of code readability.

So I decided to dedicate a store/ directory for all my Redux actions/reducers. This method is mostly borrowed from this guide made by Tal Kol of Wix, with a few adjustments.

.
|-- components
|   |-- Footer.tsx
|   `-- Header.tsx
|-- containers
|   `-- LayoutContainer.tsx
|-- pages
|   |-- index.tsx
|   |-- matches.tsx
|   `-- heroes.tsx
|-- store
|   |-- heroes
|   |   |-- actions.ts
|   |   |-- reducer.ts
|   |   `-- types.ts
|   |-- layout
|   |   |-- actions.ts
|   |   |-- reducer.ts
|   |   `-- types.ts
|   `-- index.ts
|-- ...
|-- index.tsx
`-- types.d.ts

Group stores by context

As an extension to the guides above, the state tree should be structured by context.

.
`- store
    |-- heroes // Handles application states inside the `/heroes` page.
    |   |-- actions.ts
    |   |-- reducer.ts
    |   `-- types.ts
    ├── layout // Handles global layout settings, e.g. theme, small/large text.
    |   |-- actions.ts
    |   |-- reducer.ts
    |   `-- types.ts
    `-- index.ts

Combine reducers inside store/index.ts

Include an index.ts file at the root of the store/ directory. We’ll use this to declare the top-level application state object type, as well as exporting our combined reducers.

// ./src/store/index.ts

import { combineReducers, Dispatch, Reducer, Action, AnyAction } from 'redux'
import { LayoutState, layoutReducer } from './layout'

// The top-level state object.
//
// `connected-react-router` already injects the router state typings for us,
// so we can ignore them here.
export interface ApplicationState {
  layout: LayoutState
}

// Whenever an action is dispatched, Redux will update each top-level application state property
// using the reducer with the matching name. It's important that the names match exactly, and that
// the reducer acts on the corresponding ApplicationState property type.
export const rootReducer = combineReducers<ApplicationState>({
  layout: layoutReducer,
})

Store types

Include a types.ts file inside each store module. This is where we hold our state types, as well as any other types related to this Redux store module.

// ./src/store/heroes/types.ts

// Response object for GET /heroes
// https://docs.opendota.com/#tag/heroes%2Fpaths%2F~1heroes%2Fget
export interface Hero {
  id: number
  name: string
  localized_name: string
  primary_attr: string
  attack_type: string
  roles: string[]
  legs: number
}

// This type is basically shorthand for `{ [key: string]: any }`. Feel free to replace `any` with
// the expected return type of your API response.
export type ApiResponse = Record<string, any>

// Use `const enum`s for better autocompletion of action type names. These will
// be compiled away leaving only the final value in your compiled code.
//
// Define however naming conventions you'd like for your action types, but
// personally, I use the `@@context/ACTION_TYPE` convention, to follow the convention
// of Redux's `@@INIT` action.
export const enum HeroesActionTypes {
  FETCH_REQUEST = '@@heroes/FETCH_REQUEST',
  FETCH_SUCCESS = '@@heroes/FETCH_SUCCESS',
  FETCH_ERROR = '@@heroes/FETCH_ERROR',
  SELECTED = '@@heroes/SELECTED'
}

// Declare state types with `readonly` modifier to get compile time immutability.
// https://github.com/piotrwitek/react-redux-typescript-guide#state-with-type-level-immutability
export interface HeroesState {
  readonly loading: boolean
  readonly data: Hero[]
  readonly errors?: string
}

Typing actions

Now that we have everything scaffolded, time to set up our actions!

Writing typesafe actions with typesafe-actions

Piotrek Witek created the typesafe-actions library, which provides useful helper functions to create type-safe Redux actions. We’ll use this to write our Redux actions.

// ./src/store/heroes/actions.ts

import { action } from 'typesafe-actions'
import { HeroesActionTypes, Hero } from './types'

// Here we use the `action` helper function provided by `typesafe-actions`.
// This library provides really useful helpers for writing Redux actions in a type-safe manner.
// For more info: https://github.com/piotrwitek/typesafe-actions
export const fetchRequest = () => action(HeroesActionTypes.FETCH_REQUEST)

// Remember, you can also pass parameters into an action creator. Make sure to
// type them properly as well.
export const fetchSuccess = (data: Hero[]) => action(HeroesActionTypes.FETCH_SUCCESS, data)
export const fetchError = (message: string) => action(HeroesActionTypes.FETCH_ERROR, message)

Typing reducers

Typing reducers is a lot more straightforward with Redux 4.

// ./src/store/heroes/reducer.ts

import { Reducer } from 'redux'
import { HeroesState, HeroesActionTypes } from './types'

// Type-safe initialState!
const initialState: HeroesState = {
  data: [],
  errors: undefined,
  loading: false
}

// Thanks to Redux 4's much simpler typings, we can take away a lot of typings on the reducer side,
// everything will remain type-safe.
const reducer: Reducer<HeroesState> = (state = initialState, action) => {
  switch (action.type) {
    case HeroesActionTypes.FETCH_REQUEST: {
      return { ...state, loading: true }
    }
    case HeroesActionTypes.FETCH_SUCCESS: {
      return { ...state, loading: false, data: action.payload }
    }
    case HeroesActionTypes.FETCH_ERROR: {
      return { ...state, loading: false, errors: action.payload }
    }
    default: {
      return state
    }
  }
}

// Instead of using default export, we use named exports. That way we can group these exports
// inside the `index.js` folder.
export { reducer as heroesReducer }

Handling actions asynchronously with redux-saga

If your action dispatcher involves making numerous asynchronous tasks, it’s better to include a library which handles side-effects on Redux. The two commonly-used libraries for this are redux-thunk and redux-saga. We’re going to use redux-saga due to its cleaner API, which makes use of generator functions.

// ./src/store/heroes/sagas.ts

import { all, call, fork, put, takeEvery } from 'redux-saga/effects'
import { HeroesActionTypes } from './types'
import { fetchError, fetchSuccess } from './actions'
import callApi from '../../utils/callApi'

const API_ENDPOINT = process.env.REACT_APP_API_ENDPOINT || ''

// Here we use `redux-saga` to trigger actions asynchronously. `redux-saga` uses something called a
// "generator function", which you can read about here:
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*

function* handleFetch() {
  try {
    // To call async functions, use redux-saga's `call()`.
    const res = yield call(callApi, 'get', API_ENDPOINT, '/heroes')

    if (res.error) {
      yield put(fetchError(res.error))
    } else {
      yield put(fetchSuccess(res))
    }
  } catch (err) {
    if (err instanceof Error) {
      yield put(fetchError(err.stack!))
    } else {
      yield put(fetchError('An unknown error occured.'))
    }
  }
}

// This is our watcher function. We use `take*()` functions to watch Redux for a specific action
// type, and run our saga, for example the `handleFetch()` saga above.
function* watchFetchRequest() {
  yield takeEvery(HeroesActionTypes.FETCH_REQUEST, handleFetch)
}

// We can also use `fork()` here to split our saga into multiple watchers.
function* heroesSaga() {
  yield all([fork(watchFetchRequest)])
}

export default heroesSaga

To include them in our root store, we add a rootSaga() generator function which collects all of our store sagas.

// ./src/store/index.ts

import { all, fork } from 'redux-saga/effects'

// We `fork()` these tasks so they execute in the background.
export function* rootSaga() {
  yield all([
    fork(heroesSaga),
    fork(teamsSaga),
    // `fork()` any other store sagas down here...
  ])
}

Initialising Redux store

Initialising the Redux store should be done inside a configureStore() function. Inside this function, we bootstrap the required middlewares (including redux-saga) and combine them with our reducers.

// ./src/configureStore.ts

import { Store, createStore, applyMiddleware } from 'redux'
import createSagaMiddleware from 'redux-saga'
// `react-router-redux` is deprecated, so we use `connected-react-router`.
// This provides a Redux middleware which connects to our `react-router` instance.
import { connectRouter, routerMiddleware } from 'connected-react-router'
// We'll be using Redux Devtools. We can use the `composeWithDevTools()`
// directive so we can pass our middleware along with it
import { composeWithDevTools } from 'redux-devtools-extension'
// If you use react-router, don't forget to pass in your history type.
import { History } from 'history'

// Import the state interface and our combined reducers/sagas.
import { ApplicationState, rootReducer, rootSaga } from './store'

export default function configureStore(
  history: History,
  initialState: ApplicationState
): Store<ApplicationState> {
  // create the composing function for our middlewares
  const composeEnhancers = composeWithDevTools({})
  // create the redux-saga middleware
  const sagaMiddleware = createSagaMiddleware()

  // We'll create our store with the combined reducers/sagas, and the initial Redux state that
  // we'll be passing from our entry point.
  const store = createStore(
    connectRouter(history)(rootReducer),
    initialState,
    composeEnhancers(applyMiddleware(routerMiddleware(history), sagaMiddleware))
  )

  // Don't forget to run the root saga, and return the store object.
  sagaMiddleware.run(rootSaga)
  return store
}

Connecting with React

Now let’s hook everything up with React.

Container components

Before React v16, I would’ve suggested against implementing container components that are separate from their connected view logic, since they intrude at the very definition of a view. But with newer patterns, like render props, it makes sense to use them once again.

First, we separate props to their own interfaces for better readability.

// Separate state props + dispatch props to their own interfaces.

// Props passed from mapStateToProps
interface PropsFromState {
  theme: ThemeColors
}

// Props passed from mapDispatchToProps
interface PropsFromDispatch {
  setTheme: typeof layoutActions.setTheme
}

// Component-specific props.
interface OtherProps {
  children: (props: LayoutContainerProps) => React.ReactNode
}

// Combine both state + dispatch props - as well as any props we want to pass - in a union type.
type LayoutContainerProps = PropsFromState & PropsFromDispatch

Then we declare our component.

// We separate OtherProps instead in combining them in the union type above for us to
// use the children prop pattern without typing issues.
class LayoutContainer extends React.Component<LayoutContainerProps & OtherProps> {
  public render() {
    const { children, ...rest } = this.props

    return children({ ...rest })
  }
}

This way, we can use the Redux store linking from any component!

// ./src/components/layouts/Header.tsx

import * as React from 'react'
import LayoutContainer from '../../containers/LayoutContainer'

const Header: React.SFC = ({ children }) => (
  <LayoutContainer>
    {({ theme, setTheme }) => (
      <React.Fragment>
        <CurrentTheme>Current theme: {theme}</CurrentTheme>
        <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
          Switch theme
        </button>
      </React.Fragment>
    )}
  </LayoutContainer>
)

export default Header

Page components

When connecting pure React components, it’s a good idea to connect them at the page level. As a reminder, when mapping states/action dispatcher to a component, we need to combine the state/action dispatcher prop types of the store we’re mapping to our component prop types as well.

// Separate state props + dispatch props to their own interfaces.
interface PropsFromState {
  loading: boolean
  data: Hero[]
  errors: string
}

// We can use `typeof` here to map our dispatch types to the props, like so.
interface PropsFromDispatch {
  fetchRequest: typeof heroesActions.fetchRequest
}

// Combine both state + dispatch props - as well as any props we want to pass - in a union type.
type AllProps = PropsFromState & PropsFromDispatch & ConnectedReduxProps

const API_ENDPOINT = process.env.REACT_APP_API_ENDPOINT || ''

class HeroesIndexPage extends React.Component<AllProps> {
  public componentDidMount() {
    this.props.fetchRequest()
  }

  public render() {
    const { loading } = this.props

    return (
      <Page>
        <Container>
          <TableWrapper>
            {loading && <LoadingOverlay />}
            {this.renderData()}
          </TableWrapper>
        </Container>
      </Page>
    )
  }

  private renderData() {
    const { loading, data } = this.props

    return (
      <HeroesTable columns={['Name', 'Legs']} widths={['auto', '120px']}>
        {loading &&
          data.length === 0 && (
            <HeroLoading>
              <td colSpan={2}>Loading...</td>
            </HeroLoading>
          )}
        {data.map(hero => (
          <tr key={hero.id}>
            <HeroDetail>
              <HeroIcon src={API_ENDPOINT + hero.icon} alt={hero.name} />
              <HeroName>
                <Link to={`/heroes/${hero.name}`}>{hero.localized_name}</Link>
              </HeroName>
            </HeroDetail>
            <td>{hero.legs}</td>
          </tr>
        ))}
      </HeroesTable>
    )
  }
}

Using react-redux’s connect()

The react-redux connect() function is what connects our React component to the redux store.

// ./src/pages/heroes.tsx

import { connect, Dispatch } from 'react-redux'
import { ApplicationState } from '../store'

// ...

// It's usually good practice to only include one context at a time in a connected component.
// Although if necessary, you can always include multiple contexts. Just make sure to
// separate them from each other to prevent prop conflicts.
const mapStateToProps = ({ heroes }: ApplicationState) => ({
  loading: heroes.loading,
  errors: heroes.errors,
  data: heroes.data
})

// mapDispatchToProps is especially useful for constraining our actions to the connected component.
// You can access these via `this.props`.
const mapDispatchToProps = (dispatch: Dispatch) => ({
  fetchRequest: () => dispatch(heroesActions.fetchRequest())
})

// Now let's connect our component!
// With redux v4's improved typings, we can finally omit generics here.
export default connect(
  mapStateToProps,
  mapDispatchToProps
)(HeroesPage)

Additional helper type

We can also add a helper type for our connected Redux components.

// Additional props for connected React components. This prop is passed by default with `connect()`
export interface ConnectedReduxProps<A extends Action = AnyAction> {
  // Correct types for the `dispatch` prop passed by `react-redux`.
  // Additional type information is given through generics.
  dispatch: Dispatch<A>
}

So now on any Redux-connected component, we can extend its props interface with the interface above

// Extend the interface (for example).
interface ComponentProps extends ConnectedReduxStore {}

class Component extends React.Component<ComponentProps> {
  public componentDidMount() {
    // We can use the extended interface above as follows.
    this.props.dispatch(layoutActions.fetchRequest())
  }
}

Sample code

Hope you’ve found this guide useful! Based on your feedback as well, I’ve also published a sample project following the guides above on GitHub. Click here to go there.

References

To learn more about React, Redux, and TypeScript, the following guides are a good read: