12 August 2019

cover

React hooks-oriented Redux coding pattern without thunks and action creators

With TypeScript
Share on:

Motivation

I love Redux. But that doesn’t mean I like all parts of Redux ecosystem. Some people dislike Redux because of its boilerplate code. That’s sad. Boilerplate code is not from the Redux core, but from the ecosystem. Don’t get me wrong. Best practices are nice and I think the recent work of Redux Starter Kit is great. (Claps to Mark)

I think I have my own understanding of how to use Redux with React. It may not be common and probably it will never be the mainstream. I understand Redux is useful and tuned for larger applications. What I have in mind is the usage for smaller apps and for beginners.

For smaller apps and for beginners, there seems to be several hurdles. The first one for me was mapStateToProps. I developed reactive-react-redux to solve it. It provides super simple useTrackedState. It was developed before the Redux hooks API is available. Now, useSelector from the new hooks API is so nice. It’s much less ugly than mapStateToProps. Note that useTrackedState is still easier, because it doesn’t require memoization for optimization.

Another hurdle for me is async actions. I generally like the middleware system of Redux and the elegance of the implementation of redux-thunk. But, I find some difficulties with it. Basically, it’s too flexible. It’s like exposing the middleware system to userland, to some extent. Just like people misuse selectors having heavy computation, people misuse thunks, or overuse them. redux-observable and redux-saga seem to provide better abstraction but they are complex systems. They would fit with larger apps.

So, in this post, I would like to show example code as an alternative pattern. It doesn’t use middleware, but React custom hooks. Here’re some points in this pattern.

  • No async libraries (Run async tasks outside of Redux)
  • No action creators (Define action types in TypeScript)

Without a word, let’s dive into the code.

(By the way, yet another hurdle for me is combineReducers, but it’s out of the scope of this post.)

Example

The example to use is Async Actions in the official Redux Advanced Tutorial.

Code

Folder structure

- src/
  - index.tsx
  - store/
    - actions.ts
    - reducers.ts
  - hooks/
    - useSelectSubreddit.ts
    - useInvalidateSubreddit.ts
    - useFetchPostsIfNeeded.ts
  - components/
    - App.tsx
    - Picker.tsx
    - Posts.tsx

src/index.tsx

import * as React from 'react';
import { render } from 'react-dom';
import { Provider } from 'react-redux';

import rootReducer from './store/reducers';
import App from './components/App';

const store = createStore(rootReducer);

render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('app'),
);

This is the entry point. Nothing special in this file.

src/store/actions.ts

export type Post = {
  id: string;
  title: string;
};

export type SubredditPosts = {
  isFetching: boolean;
  didInvalidate: boolean;
  items: Post[];
  lastUpdated?: number;
};

export type PostsBySubreddit = {
  [subreddit: string]: SubredditPosts;
};

export type SelectedSubreddit = string;

export type State = {
  selectedSubreddit: SelectedSubreddit;
  postsBySubreddit: PostsBySubreddit;
};

type SelectSubredditAction = {
  type: 'SELECT_SUBREDDIT';
  subreddit: string;
};

type InvalidateSubredditAction = {
  type: 'INVALIDATE_SUBREDDIT';
  subreddit: string;
};

type RequestPostsAction = {
  type: 'REQUEST_POSTS';
  subreddit: string;
};

type ReceivePostsAction = {
  type: 'RECEIVE_POSTS';
  subreddit: string;
  posts: Post[];
  receivedAt: number;
};

export type Action =
  | SelectSubredditAction
  | InvalidateSubredditAction
  | RequestPostsAction
  | ReceivePostsAction;

This defines State and Action types. No action constants and no action creators are defined.

src/store/reducers.ts

import { combineReducers } from 'redux';
import {
  SubredditPosts,
  SelectedSubreddit,
  PostsBySubreddit,
  State,
  Action,
} from './actions';

const selectedSubreddit = (
  state: SelectedSubreddit = 'reactjs',
  action: Action,
): SelectedSubreddit => {
  switch (action.type) {
    case 'SELECT_SUBREDDIT':
      return action.subreddit;
    default:
      return state;
  }
};

const posts = (state: SubredditPosts = {
  isFetching: false,
  didInvalidate: false,
  items: [],
}, action: Action): SubredditPosts => {
  switch (action.type) {
    case 'INVALIDATE_SUBREDDIT':
      return {
        ...state,
        didInvalidate: true,
      };
    case 'REQUEST_POSTS':
      return {
        ...state,
        isFetching: true,
        didInvalidate: false,
      };
    case 'RECEIVE_POSTS':
      return {
        ...state,
        isFetching: false,
        didInvalidate: false,
        items: action.posts,
        lastUpdated: action.receivedAt,
      };
    default:
      return state;
  }
};

const postsBySubreddit = (
  state: PostsBySubreddit = {},
  action: Action,
): PostsBySubreddit => {
  switch (action.type) {
    case 'INVALIDATE_SUBREDDIT':
    case 'RECEIVE_POSTS':
    case 'REQUEST_POSTS':
      return {
        ...state,
        [action.subreddit]: posts(state[action.subreddit], action),
      };
    default:
      return state;
  }
};

const rootReducer = combineReducers<State>({
  postsBySubreddit,
  selectedSubreddit,
});

export default rootReducer;

This is a normal reducer file with type annotations. Note that we don’t use any explicit and implicit any.

src/hooks/useSelectSubreddit.ts

import { useCallback } from 'react';
import { useDispatch } from 'react-redux';

import { Action } from '../store/actions';

const useSelectSubreddit = () => {
  const dispatch = useDispatch<Action>();
  const selectSubreddit = useCallback((subreddit: string) => {
    dispatch({
      type: 'SELECT_SUBREDDIT',
      subreddit,
    });
  }, [dispatch]);
  return selectSubreddit;
};

export default useSelectSubreddit;

This is something instead of an action creator. It’s a hook to return a callback function that creates and dispatches an action. Let’s call it “an action hook.” This one is a sync action hook.

src/hooks/useInvalidateSubreddit.ts

import { useCallback } from 'react';
import { useDispatch } from 'react-redux';

import { Action } from '../store/actions';

const useInvalidateSubreddit = () => {
  const dispatch = useDispatch<Action>();
  const invalidateSubreddit = useCallback((subreddit: string) => {
    dispatch({
      type: 'INVALIDATE_SUBREDDIT',
      subreddit,
    });
  }, [dispatch]);
  return invalidateSubreddit;
};

export default useInvalidateSubreddit;

This is another sync action hook.

src/hooks/useFetchPostsIfNeeded.ts

import { useCallback } from 'react';
import { useDispatch, useStore } from 'react-redux';

import { Action, State, Post } from '../store/actions';

const shouldFetchPosts = (state: State, subreddit: string) => {
  const posts = state.postsBySubreddit[subreddit];
  if (!posts) {
    return true;
  }
  if (posts.isFetching) {
    return false;
  }
  return posts.didInvalidate;
};

const extractPosts = (json: unknown): Post[] | null => {
  try {
    const posts: Post[] = (json as {
      data: {
        children: {
          data: {
            id: string;
            title: string;
          };
        }[];
      };
    }).data.children.map(child => child.data);
    // type check
    if (posts.every(post => (
      typeof post.id === 'string' && typeof post.title === 'string'
    ))) {
      return posts;
    }
    return null;
  } catch (e) {
    return null;
  }
};

const useFetchPostsIfNeeded = () => {
  const dispatch = useDispatch<Action>();
  const store = useStore<State>();
  const fetchPostsIfNeeded = useCallback(async (subreddit: string) => {
    if (!shouldFetchPosts(store.getState(), subreddit)) {
      return;
    }
    dispatch({
      type: 'REQUEST_POSTS',
      subreddit,
    });
    const response = await fetch(`https://www.reddit.com/r/${subreddit}.json`);
    const json = await response.json();
    const posts = extractPosts(json);
    if (!posts) throw new Error('unexpected json format');
    dispatch({
      type: 'RECEIVE_POSTS',
      subreddit,
      posts,
      receivedAt: Date.now(),
    });
  }, [dispatch, store]);
  return fetchPostsIfNeeded;
};

export default useFetchPostsIfNeeded;

This one is an async action hook. This is the logic that would be written with thunk. There are a few important points in this file.

  • Because this is not middleware, we don’t have direct access to the state. It uses useStore, which is something we shouldn’t misuse. This is the biggest caveat in this entire pattern.
  • extractPosts is a kind of type guard to test json from the network.
  • We don’t implement error handing as is in the original tutorial.

src/components/App.tsx

import * as React from 'react';
import { useCallback, useEffect } from 'react';
import { useSelector } from 'react-redux';

import { State, SelectedSubreddit } from '../store/actions';
import useSelectSubreddit from '../hooks/useSelectSubreddit';
import useFetchPostsIfNeeded from '../hooks/useFetchPostsIfNeeded';
import useInvalidateSubreddit from '../hooks/useInvalidateSubreddit';

import Picker from './Picker';
import Posts from './Posts';

const App: React.FC = () => {
  const selectedSubreddit = useSelector((state: State) => state.selectedSubreddit);
  const postsBySubreddit = useSelector((state: State) => state.postsBySubreddit);
  const {
    isFetching,
    items: posts,
    lastUpdated,
  } = postsBySubreddit[selectedSubreddit] || {
    isFetching: true,
    items: [],
    lastUpdated: undefined,
  };

  const fetchPostsIfNeeded = useFetchPostsIfNeeded();
  useEffect(() => {
    fetchPostsIfNeeded(selectedSubreddit);
  }, [fetchPostsIfNeeded, selectedSubreddit]);

  const selectSubreddit = useSelectSubreddit();
  const handleChange = useCallback((nextSubreddit: SelectedSubreddit) => {
    selectSubreddit(nextSubreddit);
  }, [selectSubreddit]);

  const invalidateSubreddit = useInvalidateSubreddit();
  const handleRefreshClick = (e: React.MouseEvent) => {
    e.preventDefault();
    invalidateSubreddit(selectedSubreddit);
    fetchPostsIfNeeded(selectedSubreddit);
  };

  const isEmpty = posts.length === 0;
  return (
    <div>
      <Picker
        value={selectedSubreddit}
        onChange={handleChange}
        options={['reactjs', 'frontend']}
      />
      <p>
        {lastUpdated && (
          <span>
            Last updated at {new Date(lastUpdated).toLocaleTimeString()}.
            {' '}
          </span>
        )}
        {!isFetching && (
          <button type="button" onClick={handleRefreshClick}>
            Refresh
          </button>
        )}
      </p>
      {isEmpty && isFetching && <h2>Loading...</h2>}
      {isEmpty && !isFetching && <h2>Empty.</h2>}
      {!isEmpty && (
        <div style={{ opacity: isFetching ? 0.5 : 1 }}>
          <Posts posts={posts} />
        </div>
      )}
    </div>
  );
};

export default App;

This is a root component or a container component. Unfortunately, the code looks like boilerplate. But, it should be mostly the same with a normal React app. I think the second caveat in this pattern is requiring the useCallback hook.

src/components/Picker.tsx

import * as React from 'react';

const Picker: React.FC<{
  value: string;
  onChange: (value: string) => void;
  options: string[];
}> = ({ value, onChange, options }) => (
  <span>
    <h1>{value}</h1>
    <select
      onChange={e => onChange(e.target.value)}
      value={value}
    >
      {options.map(option => (
        <option value={option} key={option}>
          {option}
        </option>
      ))}
    </select>
  </span>
);

export default Picker;

This is a stateless component. Nothing is changed except for type annotations.

src/components/Posts.tsx

import * as React from 'react';

const Posts: React.FC<{
  posts: {
    id: string;
    title: string;
  }[];
}> = ({ posts }) => (
  <ul>
    {posts.map(post => (
      <li key={post.id}>{post.title}</li>
    ))}
  </ul>
);

export default Posts;

This is another stateless component. We could import Post from actions.ts.

That’s everything. We are all set.

Demo

Source code in the repo

Notice this code is based on reactive-react-redux instead of react-redux. reactive-react-redux has a compatible hooks API with react-redux, except for useStore. In this demo, useStore is implemented with another context.

Closing notes

This coding pattern may not be new, and I’m sure somebody else has already tried it out. However, it makes more sense with React hooks and TypeScript. It can eliminate some boilerplate code. This example uses isFetching flag to show a loading status, but that will change with React Suspense. This pattern should ease the transition to React Suspense.

comments powered by Disqus