piotrwitek / react-redux-typescript-guide

The complete guide to static typing in "React & Redux" apps using TypeScript
https://piotrwitek.github.io/react-redux-typescript-guide/
MIT License
13.35k stars 1.09k forks source link

Improvement suggestion around dispatching a fetch action from within a functional component and handling errors #186

Open dcs3spp opened 5 years ago

dcs3spp commented 5 years ago

Hi,

Firstly, I have found the patterns in the guide very helpful for configuring actions, epics, reducers etc. However, I do not understand how to dispatch a fetch API request action and subsequently bind to a fetch successful action. I have managed to create an epic for a fetch API request that then triggers a success or error action depending upon the success/failure of the API request.

I do not understand how to hook this up to a functional component, as highlighted in the comments of the CourseList functional component code listing below:

CourseList Functional Component

import React, { useEffect } from 'react';

import Grid from '@material-ui/core/Grid';
import { GridSpacing } from '@material-ui/core/Grid';

import Course from '../../components/Course/Course';
import { Course as CourseModel } from '../../redux/features/course/model';

type Props = {
  courses: CourseModel[];
  onFetchCourseRequest: ??? // I want to bind this to the fetch courses action but do not understand how?? what type should this be??
};

export const CourseList: React.FC<Props> = props => {
  const { courses, onFetchCourseRequest } = props;

  // functional equivalent of componentDidMount 
  // this only gets triggered once, since
  // dependency list is empty
  // I want this to to trigger the dispatch of a FETCH_COURSE_LIST action and bind to resulting FETCH_COURSE_LIST
  // I do not understand how to do this from examples....
  useEffect(() => {
    onFetchCourseRequest();
  }, []);

  return (
    <div style={{ marginTop: 20, padding: 30 }}>
      {
        <Grid container spacing={2 as GridSpacing} justify="center">
          {courses.map(element => (
            <Grid item key={element.courseID}>
              <Course course={element} />
            </Grid>
          ))}
        </Grid>
      }
    </div>
  );
};

CourseListConnected

import { RootState } from 'ReduxTypes';

import { connect } from 'react-redux';

import { CourseList } from './CourseList';
import { courseActions, courseSelectors } from '../../redux/features/course';

const mapDispatchToProps = {
  onFetchCoursesRequest: courseActions.fetchCoursesRequest,
};

const mapStateToProps = (state: RootState) => ({
  courses: courseSelectors.getReduxCourses(state.courses.fetchCoursesSuccess),
});

const CourseListConnected = connect(
  mapStateToProps,
  mapDispatchToProps,
)(CourseList);

export default CourseListConnected;

Epic for Fetching a Course from API

import { RootAction, RootState, Services } from 'ReduxTypes';
import { Epic } from 'redux-observable';

import { isOfType } from 'typesafe-actions';

import { of } from 'rxjs';
import {
  catchError,
  filter,
  ignoreElements,
  map,
  switchMap,
} from 'rxjs/operators';

import { fetchCoursesFail, fetchCoursesSuccess } from './actions';

import constants from './constants';

export const fetchCoursesRequestAction: Epic<
  RootAction,
  RootAction,
  RootState,
  Services
> = (action$, state$, { courseServices }) =>
  action$.pipe(
    filter(isOfType(constants.FETCH_COURSES_REQUEST)),
    switchMap(action =>
      courseServices.default.getCourses().pipe(
        map(courses => fetchCoursesSuccess(courses)),
        catchError(error => of(fetchCoursesFail(error))),
      ),
    ),
    ignoreElements(), // ignore everything except complete and error, template does this
  );
piotrwitek commented 5 years ago

Hey,

1)

type Props = {
  courses: CourseModel[];
  onFetchCourseRequest: () => void // or typeof courseActions.fetchCoursesRequest
};

2) I dunno who told to use the below line but it is blocking your epic to emit any changes back to the redux store: ignoreElements(), // ignore everything except complete and error, template does this

A short clarification: actions$ epic should never complete because your epic will stop working. So that's why "ignore everything except complete and error" doesn't make sense in this case in which what you really want is your "response" to be pushed to the redux state.

EDIT: If there is a particular piece of information that was misleading you in the guide please point me to it so I can fix it. Thanks!

dcs3spp commented 5 years ago

Hi @piotrwitek, many thanks for responding :) Overall I have found the guide very useful for configuring actions, epics, reducers etc. Also I have found using typesafe-actions, createAsyncAction etc. useful.

I have been using the playground example, specifically FCCounter and FCCounter connected (using React.FC...) as an example for how to hook up functional component to actions. Also initially used, the epic from todos. Additionally found the todo's reference implementation useful.

I have since modified code and got it working, as listed below. In the end I included mapStateToProps and dispatchProps in the same source file as the functional component.

My particular struggles as a new user so far have been:

Container

import React, { useEffect } from 'react';

import { connect } from 'react-redux';
import Grid from '@material-ui/core/Grid';
import { GridSpacing } from '@material-ui/core/Grid';

import Course from '../components/Course/Course';

import { courseModels } from '../redux/features/course';
import { courseSelectors } from '../redux/features/course';
import { fetchCoursesAsync } from '../redux/features/course/actions';
import { RootState } from 'ReduxTypes';

type ErrorReport = { hasError: boolean; error?: Error };
type StateProps = {
  isLoading: boolean;
  courses: courseModels.Course[];
  error: ErrorReport;
};

/**
 * Redux dispatch and state mappings
 */
const dispatchProps = {
  fetchCourses: fetchCoursesAsync.request,
};

const mapStateToProps = (state: RootState): StateProps => ({
  isLoading: state.courses.isLoadingCourses,
  courses: courseSelectors.getReduxCourses(state.courses),
  error: courseSelectors.getReduxCoursesError(state.courses),
});

/**
 * Component property type definitions
 */
type Props = ReturnType<typeof mapStateToProps> & typeof dispatchProps;

/**
 * CourseList component
 */
const CourseList = ({
  courses = [],
  error,
  fetchCourses,
  isLoading,
}: Propas): JSX.Element => {
  // fetch course action on mount
  useEffect(() => {
    console.log('COURSELIST FETCHING COURSES');
    fetchCourses();
  }, [fetchCourses]);

  if (isLoading) {
    return <p>Loading...</p>;
  }

  if (error && error.hasError && error.error) {
    throw error.error;
    // if throw an error then encapsulating error boundary catches and displays.
    // However when the container is loaded again via clicking on a Navbar link the useEffect
    // action does not trigger. 

    // Alternatively, if the error is rendered inside the container then the useEffect hook is 
    // still activated if the container is loaded again (e.g. via clicking on a Navbar link).
    // return <p>{JSON.stringify(error.error, null, 2)}</p>;
  }

  return (
    <div style={{ marginTop: 20, padding: 30 }}>
      {
        <Grid container spacing={2 as GridSpacing} justify="center">
          {courses.map(element => (
            <Grid item key={element.courseID}>
              <Course course={element} />
            </Grid>
          ))}
        </Grid>
      }
    </div>
  );
};

/**
 * Exports
 */
export default connect(
  mapStateToProps,
  dispatchProps,
)(CourseList);

Epic

import { Epic } from 'redux-observable';
import { isActionOf } from 'typesafe-actions';
import { of } from 'rxjs';
import { catchError, filter, map, switchMap } from 'rxjs/operators';

import { fetchCoursesAsync } from './actions';
import { RootAction, RootState, Services } from 'ReduxTypes';

export const fetchCoursesRequestAction: Epic<
  RootAction,
  RootAction,
  RootState,
  Services
> = (action$, state$, { courseServices }) =>
  action$.pipe(
    filter(isActionOf(fetchCoursesAsync.request)),
    switchMap(() =>
      courseServices.default.getCourses().pipe(
        map(fetchCoursesAsync.success),
        catchError((error: Error) =>
          of(fetchCoursesAsync.failure({ hasError: true, error: error })),
        ),
      ),
    ),
  );
piotrwitek commented 5 years ago

Thanks for the feedback, I'll think about some ways to improve the guide in the mentioned areas. Error handling all depends where and how you want to display errors in your application.

It can be global by some kind of alerts (invoked from within epic by side effect using tap operator) or locally by just rendering error.message in error section in your view component.

dcs3spp commented 5 years ago

Thanks @piotrwitek. I am currently having my fetch epic place an error object on the redux state which is then picked up in the client component. However, experiencing problems with useEffect synchronising with checking for error in render of the functional component as detailed here.

There are very few tutorials and resources that I have looked at "so far" that seem to give an end to end example of error handling fetch requests on redux store, i.e. from epic http handling to consuming in client container/component a http success or http failure action. They are probably out there, I just have not searched far enough yet, lol. So, I think that a recommended pattern for error handling would definitely be a very popular topic if it was present in the guide for users that are new to react/redux....

piotrwitek commented 5 years ago

@dcs3spp here is an example implementation from my WIP side-project about global error handling: https://github.com/piotrwitek/react-redux-typescript-realworld-app/blob/master/src/features/articles/epics.ts#L46

Unfortunately, I don't have much time to work on each of my side-projects full time so I would need some funding to add missing parts as local error handling implementation there.

You can always create a new feature request there to start tracking it and gathering funds.

dcs3spp commented 5 years ago

Many thanks @piotrwitek. Very helpful. I will bookmark the link. It looks like I am doing the same thing with respect to error handling in the epics, i.e. dispatching an action for request success / error with message or custom payload.

My current difficulties have been processing a dispatched error action within a client component. I have included the details and my current thoughts below and might later link or raise on the react-redux-typescript-realworld-app repository.....Apologies, in advance for the lengthy discussion below....

Within a client component, I have used the useEffect hook for fetching data - a fetch action resets any previous associated error state with that fetch action. Once the client component throws an error it is displayed in the Error Boundary's material-ui dialog. When I then revisit the route associated with the client component the useEffect is not being triggered to perform another fetch. I think this is because the client component is already initialised and in an exceptional / failed status.

Because the fetch request is not issued:

  1. The associated error state is not cleared.
  2. The error on the redux store is thrown again and the error boundary dialog is displayed again.

Hence I resorted to having the client component redirect to an error page instead of throwing the error. This way when I revisit the link associated with the client component the component is reinitialised and useEffect is triggered to issue the fetch request. The fetch request is made, thus clearing the previous error state associated with the request.

This is almost working, but 'fails' for the following sequence of events:

  1. Stop external API server.
  2. Visit the link associated with client component. On load it dispatches fetch request action onto redux store.
  3. Epic tries to make request but network connection error occurs. Epic dispatches a fail action for the request.
  4. Client component detects fetch error state on redux store and is redirected to error page.
  5. Restart external API server.
  6. Visit the link associated with client component again. Redirected to error page.
  7. Visit the link associated with client component again. Success, the fetched data is displayed.

Alternatively, instead of displaying a dialog or being redirected to an error page, the client could have a dedicated renderer associated with error state. For example, if an error occurs the component does not display a data table but instead displays a formatted error text paragraph.....

piotrwitek commented 5 years ago

It seems you can have a problem of not unmounting the component when an error occurs, that's why it doesn't run request to fetch the data and clear error on the mount.

Not directly related: You're using [fetchCourses] as a "useEffect" dependency which doesn't make any sense.

Key points:

  1. Run async request on mount using [] dependency
  2. I didn't saw your reducer but, remember to clear error state on request starter
  3. make sure to unmount component on error, so it can run the request again on mount and clear old error state
dcs3spp commented 5 years ago

Thanks @piotrwitek will investigate suggestions 1 and 3. Your help and suggestions are appreciated :) Yes, the reducer for fetch request:start resets error state. Will have an experiment on how to unmount functional component when error is explicitly thrown....

Many thanks again :)