TypeScript and Redux

In notebook:
Work Notes
Created at:
2019-11-16
Updated:
2020-02-06
Tags:
JavaScript

React and Redux with Typescript

My notes from the Udemy course by Stephen Grider. Please watch the course first, then come back for the notes

Please watch the course first, and come back for the notes after!

Pros and cons of using Typescript with React

The pros:

  • helps you avoid typing errors,
  • helps you understand the code organisation
  • more safety when refactoring

The cons:

  • Need to add a lot of generics
  • Lots of importing of files to access the types

The basics

Annotating the props of a class component

Create an interface, and use it as a generic for your component to describe the props. If you hover over React.Component, you will see that the generic can actually take two arguments, the first is the typing of the props, the second is for it's internal state. More on this below.

  interface AppProps {
  color: string;
}

class App extends React.Component<AppProps> {
  render() {
    return <div>{this.prpos.color}</div>
  }
}

ReactDOM.render(<App color="red" />, document.getElementById('app'));

Annotating the state of a class component

There are actually two ways to initialise the state of a class. The "classic" way is via the constructor and super call. With this syntax, you are actually extending the component's state property:

  interface AppState {
  counter: number;
}

class App extends React.Component<AppProps, AppState> {
  constructor(props: AppProps) {
    super(props);
    this.state = { counter: 0 };
  }
  //...
  render() {
    //...
    }

}

In this case, you need to provide the AppState interface as a second argument to the generic.

When setting state with classProperties

AFAIK, it's only in a proposal state, but you can use this syntax via Babel. In this pattern, you don't have to do the constructor call, just set the state directly:

  class App extends React.Component<AppProps> {
  this.state = { counter: 0 };
  //...
  render() {
    //...
  }
}

With this pattern you are completely overriding the state property of the React component, including it's own internal definition of it and so the second argument to the generic is not necessary, so above we only have <AppProps>.

Annotating functional components

You annotate the function arguments and provide a return type:

  interface AppProps {
  color: string;
}

function App(props: AppProps): JSX.Element {
  return <div>{prpos.color}</div>
}
ReactDOM.render(<App color="red" />, document.getElementById('app'));

Redux and Typescript

npm install redux react-redux axios redux-thunk

And the basic Redux setup

  import React from 'react';
import { createStore, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import { reducers } rom './reducers';

// 1. create the store, will define the reducers later
const store = createStore(reducers, applyMiddleware(thunk));

// 2. wrap your <App /> with Provide
// passing it the store (createStore)
ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('app');
);

And create the reducers:

  // src/reducers/index.ts
import { combineReducers } from 'redux';

export const reducers = combineReducers({
  counter: () => 1
})

The action creators

We will be using thunks for asynchronous actions. A thunk is a function that returns another function and it's used by the redux-thunk middleware to do async actions. The function returned by the thunk takes a dispatch function as an argument that you can call when the async process is finished.

This is one of the harder parts of using Typescript with Redux.

  import axions from 'axios';
// 2. import Dispatch to annotate
// the dispatch argument of the thunk
import { Dispatch } from 'redux';

export function fetchTodos() {
  // 1. annotate the dispatch argument
  // Stephen found it in the type definitions file
  return async function(dispatch: Dispatch) {
    const response = await axios.get(url);

    dispatch({
      type: 'FETCH_TODOS',
      payload: response.data
    });
  }
}

Let's add the annotations:

  import axions from 'axios';
import { Dispatch } from 'redux';

// 2. create the Todo interface
interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

export function fetchTodos() {
  return async function(dispatch: Dispatch) {
    // 1. annotate the promise return value
    // via a generic
    const response = await axios.get<Todo[]>(url);

    dispatch({
      type: 'FETCH_TODOS', // next we will deal with this, see below
      payload: response.data // now Typescript now that this is a Todo[]
    });
  }
}

Action types enum

The common pattern is to use an enum to list the different action types to centralise it for your application.

  // ****  src/actions/types.ts  ****
export enum ActionTypes {
  fetchTodos
}

Usually, you would expect some more verbose declaration like so:

  enum ActionTypes {
  fetchTodos = 'fetchtodos'
}

With the enum type, we don't really need to provide an actual value for our actiontype. We never actually use the fetchtodos string directly, only the reference to it (ActionTypes.fetchTodos). Typescript will internally assign some id to it, and it's sufficent for our purposes.

Now, we can reference this action on our dispatch:

  //...
dispatch({
  type: ActionTypes.fetchTodos,
  payload: response.data
})

Annotating the dispatch call

The dispatch (from Redux) can take a generic that would describe the action we want it to dispatch. This can some extra checking to your code:

  import axions from 'axios';
import { Dispatch } from 'redux';

interface Todo {
  // ...
}

// 1. define the action interface
interface FetchTodosAction {
  type: ActionTypes.fetchTodos;
  payload: Todo[];
}

export function fetchTodos() {
  return async function(dispatch: Dispatch) {
    const response = await axios.get<Todo[]>(url);

    // 2. add the generic
    dispatch<FetchTodosAction>({
      type: ActionTypes.fetchTodos, 
      payload: response.data 
    });
  }
}

This will guard you against some typos when you call the dispatch.

Aside on organising the action creators files

In a normal Javascript file, you could include several of these action creators functions (thunks, or just simple functions). But with Typescript, because of the type annotations (interface Todo above), it's better to create a separate file per each action creator.

Annotating the reducer

Again, you need to import some files, so that you use the types for the annotation:

  // ****   src/reducers/todos.ts   ****
import { Todo, FetchTodosAction } from '..actions';
import { ActionTypes } from '../actions/types';

export function todosReducer(
  state: Todo[] = [], // default value for the app init
  action: FetchTodosAction // can reuse the interface from above
  ) => {
    switch(action.type) {
      case ActionTypes.fetchTodos:
        return action.payload;
      default:
        return state;
    }
  
}

Note, that when Redux boots up, it also sends some internal actions that will go through your reducers. So the above action: FetchTodosAction annotation is not totally correct, it doesn't cover these internal Redux actions.

Annotating the Redux store

The combineReducers function accepts a generic that you can use to describe the possible shape of the store:

  // ****  src/reducers/index.ts   ****
import { todosReducer } from './todos';
import { Todo } from '../actions';

export interface StoreState {
  todos: Todo[]
}

export const reducers = combineReducers<StoreState>({
  todos: todosReducer
});

Now, Typescript will check that todosReducer will actually redurn an Todo[] (array of Todo items). Also, the StoreState definition will help other developers to understand the entire, possible states of our application.

Connecting and rendering the Redux store

As before, we will annotate the props that our App component can receive, create the mapStateToProps and mapDispatchToProps functions and connect our App.

  import React from 'react';
import { connect } from 'react-redux';
import { Todo, fetchTodos } from '..actions';
import { StoreState } from '..reducers';

// 1. create interface for the generic
// that will be passed to the component
interface AppProps {
  todos: Todo[];
  fetchTodos(): any; // just an any for now, will correct
}

// 2. pass the AppProps as a generic
class _App extends React.Component<AppProps> {
  // 5. now can fetch todos:
  onButtonClick = (): void => {
    this.props.fetchTodos();
  }
  render() {
    //...
    // render the list (pseudo code)
    this.props.todos.map((todo: Todo) => {
      return <div key={todo.id}>{todo.title}</div>
    });
  }
}

// 3. map.state.to.props:
// the StoreState is form our combineReducers declaration
function mapStateToProps(state: StoreState): { todos: Todo[] } {
  return { todos: state.todos };
}

// 4. then connect our app
export const App = connect(
  mapStateToProps,
  { fetchTodos } // reminder: see AppProps above
)(_App);

File organisation

Best to export all the action creators into separate files. So for example, create src/actions/todos.ts and put all the todo related actions there. The index.ts now should just contain export * from './todos.' exports. And finally, now you can just import { Todo, FetchTodosAction, ActionTypes } from '../actions', everything you need is there.

Type guards in reducers

The switch statement inside the reducers act as an implicit type guards. So in the different cases inside the switch, you have a guarantee of the state and payload "shape".

  // ****   src/reducers/todos.ts   ****
import { Todo, FetchTodosAction, ActionTypes } from '..actions';

export function todosReducer(
  state: Todo[] = [], 
  action: FetchTodosAction 
  ) => {
    switch(action.type) {
      case ActionTypes.fetchTodos:
        return action.payload;
      default:
        return state;
    }
}

AppProps interface

This has not been explained in the course, but this is how the final interface for AppProps looks like in App.tsx:

  // **** App.tsx *****

// ...
interface AppProps {
  todos: Todo[];
  fetchTodos: typeof fetchTodos;
  deleteTodo: typeof deleteTodo;
}
// ... 
export const App = connect(
  mapStateToProps,
  { fetchTodos } 
)(_App);

However, this creates a mismatch in Typescript, in the connect call. deleteTodo is a "normal" action creator that returns a simple object, but fetchTodos is a Redux Thunk, returning a function, that takes a dispatch argument and returns a Promise.

This is a case in Typescript, where we cannot be really more specific and cannot tell exactly what fetchTodos reall is. So we change the annotation to:

  interface AppProps {
  todos: Todo[];
  fetchTodos: Function;
  deleteTodo: typeof deleteTodo;
}

Initialising a React Component

Just repeating.

  class _App extends React.Component<AppProps, AppState> {
  // B. or you can just do
  state: { fetching: false }
  // but it would override the React internal state

  // A. extending the React state
  constructor(props: AppProps) {
    super(props);
    this.state = { fetching: false };
  }

  // just an example of a lifecycle method usage
  componentDidUpdate(prevProps: AppProps): void {
    if (!prevProps.todos.length && this.props.todos.length) {
      this.setState({ fetching: false });
    }
  }
}