16

Is it possible to call an async redux action known as a thunk on a particular route and not perform the transition until the response has succeeded or failed?

Use Case

We need to load data from the server and fill a form with initial values. These initial values don't exist until the data is fetched from the server.

some syntax like this would be great:

<Route path="/myForm" component={App} async={dispatch(loadInitialFormValues(formId))}>
AndrewMcLagan
  • 11,928
  • 19
  • 75
  • 144

3 Answers3

12

To answer the original question of preventing the transition to a new route until a response has succeeded or failed:

Because you're using redux thunk you could have the success or failure in the action creator trigger the redirect. I don't know what your specific action / action creator looks like but something like this could work:

import { browserHistory } from 'react-router'

export function loadInitialFormValues(formId) {
  return function(dispatch) {
    // hit the API with some function and return a promise:
    loadInitialValuesReturnPromise(formId)
      .then(response => {
        // If request is good update state with fetched data
        dispatch({ type: UPDATE_FORM_STATE, payload: response });

        // - redirect to the your form
        browserHistory.push('/myForm');
      })
      .catch(() => {
        // If request is bad...
        // do whatever you want here, or redirect
        browserHistory.push('/myForm')
      });
  }
}

Follow up. Common pattern of loading data on entering a route / on componentWillMount of a component and displaying a spinner:

From the redux docs on async actions http://redux.js.org/docs/advanced/AsyncActions.html

  • An action informing the reducers that the request began.

The reducers may handle this action by toggling an isFetching flag in the state. This way the UI knows it’s time to show a spinner.

  • An action informing the reducers that the request finished successfully.

The reducers may handle this action by merging the new data into the state they manage and resetting isFetching. The UI would hide the spinner, and display the fetched data.

  • An action informing the reducers that the request failed.

The reducers may handle this action by resetting isFetching. Additionally, some reducers may want to store the error message so the UI can display it.

I followed this general pattern below using your situation as a rough guideline. You do not have to use promises

// action creator:
export function fetchFormData(formId) {
  return dispatch => {
    // an action to signal the beginning of your request
    // this is what eventually triggers the displaying of the spinner
    dispatch({ type: FETCH_FORM_DATA_REQUEST })

    // (axios is just a promise based HTTP library)
    axios.get(`/formdata/${formId}`)
      .then(formData => {
        // on successful fetch, update your state with the new form data
        // you can also turn these into their own action creators and dispatch the invoked function instead
        dispatch({ type: actions.FETCH_FORM_DATA_SUCCESS, payload: formData })
      })
      .catch(error => {
        // on error, do whatever is best for your use case
        dispatch({ type: actions.FETCH_FORM_DATA_ERROR, payload: error })
      })
  }
}

// reducer

const INITIAL_STATE = {
  formData: {},
  error: {},
  fetching: false
}

export default function(state = INITIAL_STATE, action) {
  switch(action.type) {
    case FETCH_FORM_DATA_REQUEST:
      // when dispatch the 'request' action, toggle fetching to true
      return Object.assign({}, state, { fetching: true })
    case FETCH_FORM_DATA_SUCCESS:
      return Object.assign({}, state, {
        fetching: false,
        formData: action.payload
      })
    case FETCH_FORM_DATA_ERROR:
      return Object.assign({}, state, {
        fetching: false,
        error: action.payload
      })
  }
}

// route can look something like this to access the formId in the URL if you want
// I use this URL param in the component below but you can access this ID anyway you want:
<Route path="/myForm/:formId" component={SomeForm} />

// form component
class SomeForm extends Component {
  componentWillMount() {
    // get formId from route params
    const formId = this.props.params.formId
    this.props.fetchFormData(formId)
  }

  // in render just check if the fetching process is happening to know when to display the spinner
  // this could also be abstracted out into another method and run like so: {this.showFormOrSpinner.call(this)}
  render() {
    return (
      <div className="some-form">
        {this.props.fetching ? 
          <img src="./assets/spinner.gif" alt="loading spinner" /> :
          <FormComponent formData={this.props.formData} />
        }
      </div>
    )
  }
}

function mapStateToProps(state) {
  return {
    fetching: state.form.fetching,
    formData: state.form.formData,
    error: state.form.error
  }
}

export default connect(mapStateToProps, { fetchFormData })(SomeForm)
Dmitry Dushkin
  • 2,271
  • 4
  • 22
  • 36
jmancherje
  • 5,421
  • 5
  • 28
  • 50
  • 1
    That seems like an anti-pattern. I was thinking all routes that match a pattern could trigger a reload of the state to keep the whole state tree updated automatically – AndrewMcLagan Jun 16 '16 at 04:50
  • 2
    You could also have an onEnter event handler on the route, or a fetch on componentWillMount fetch the data and have a spinner while the form data is fetched instead of freezing the navigation. That's a very common use for Redux Thunk – jmancherje Jun 16 '16 at 04:52
  • That seems like the idea I'm after :-) make that an answer? – AndrewMcLagan Jun 16 '16 at 11:43
  • @AndrewMcLagan ok cool, that's a pattern that's very common and something I've done a lot so I'll write up an example for you now – jmancherje Jun 16 '16 at 16:41
  • @AndrewMcLagan I added a pretty detailed example of the whole flow of logic through action creators, reducers, and the component. Let me know what you think. – jmancherje Jun 16 '16 at 17:30
  • 2
    Any solution for React Router v4? – Sisir Jun 16 '17 at 20:10
3

First and foremost, I want to say that there is a debate around the topic of fetching data with react-router's onEnter hooks whether or not is good practice, nevertheless this is how something like that would go:

You can pass the redux-store to your Router. Let the following be your Root component, where Router is mounted:

...
import routes from 'routes-location';

class Root extends React.Component {
  render() {
    const { store, history } = this.props;

    return (
      <Provider store={store}>
        <Router history={history}>
          { routes(store) }
        </Router>
      </Provider>
    );
  }
}
...

And your routes will be something like:

import ...
...

const fetchData = (store) => {
  return (nextState, transition, callback) => {
    const { dispatch, getState } = store;
    const { loaded } = getState().myCoolReduxStore;
    // loaded is a key from my store that I put true when data has loaded

    if (!loaded) {
      // no data, dispatch action to get it
      dispatch(getDataAction())
        .then((data) => {
          callback();
        })
        .catch((error) => {
          // maybe it failed because of 403 forbitten, we can use tranition to redirect.
          // what's in state will come as props to the component `/forbitten` will mount.
          transition({
            pathname: '/forbitten',
            state: { error: error }
          });
          callback();
        });
    } else {
      // we already have the data loaded, let router continue its transition to the route
      callback();
    }
  }
};

export default (store) => {
  return (
    <Route path="/" component={App}>
      <Route path="myPage" name="My Page" component={MyPage} onEnter={fetchData(store)} />
      <Route path="forbitten" name="403" component={PageForbitten} />
      <Route path="*" name="404" component={PageNotFound} />
    </Route>
  );
};

Please notice that your router file is exporting a thunk with your store as argument, if you look upwards, see how we invoked the router, we pass the store object to it.

Sadly, at the time of writing react-router docs return 404 to me, thus I cannot point you to the docs where (nextState, transition, callback) are described. But, about those, from my memory:

  • nextState describes the route react-router will transition to;

  • transition function to preform maybe another transition than the one from nextState;

  • callback will trigger your route transition to finish.

Another think to point out is that with redux-thunk, your dispatch action can return a promise, check it in the docs here. You can find here a good example on how to configure your redux store with redux-thunk.

Dragos Rizescu
  • 3,060
  • 5
  • 27
  • 41
  • It should be noted that the "debate" you mention is now very outdated and has never been resolved, see [this issue](https://github.com/ReactTraining/react-router/issues/7783) for a clarifying statement. – bluenote10 Mar 06 '21 at 09:49
1

I made a handy hook for this purpose, works with react-router v5:

/*
 * Return truthy if you wish to block. Empty return or false will not block
 */
export const useBlock = func => {
    const { block, push, location } = useHistory()
    const lastLocation = useRef()

    const funcRef = useRef()
    funcRef.current = func

    useEffect(() => {
        if (location === lastLocation.current || !funcRef.current)
            return
        lastLocation.current = location

        const unblock = block((location, action) => {
            const doBlock = async () => {
                if (!(await funcRef.current(location, action))) {
                    unblock()
                    push(location)
                }
            }
            doBlock()
            return false
        })
    }, [location, block, push])
}

Inside your component, use it like:

const MyComponent = () => {
    useBlock(async location => await fetchShouldBlock(location))

    return <span>Hello</span>
}

Navigation will not occur until the async function returns; you can completely block the navigation by returning true.

Mordechai
  • 13,232
  • 1
  • 32
  • 69