jeffbski / redux-logic

Redux middleware for organizing all your business logic. Intercept actions and perform async processing.
MIT License
1.81k stars 107 forks source link

Question: Handling UI side-effects #29

Open kimsagro1 opened 7 years ago

kimsagro1 commented 7 years ago

When using thunks, UI side-effects such as route rediection, toast notifications etc, can be handled by chaining onto the promise returned by the thunk

For example in a form submit handler

handleSubmit(values) {
 return createEvent(values)
    .then(() => routerActions.push('/'))
}

To accomplish the same using logic I'm having to intercept the creation success action and do the redirection there

For example

const createUnpublishedActivityFulfilledLogic = createLogic({
    type: createUnpublishedActivityFulfilled,

    process({ routerActions }) {
        return routerActions.push('/')
    },
})

It doesn't seem right to me to have UI concerns within logic. It now means that the create action is no-longer re-usable as it will always cause a redirection on success.

Is there another way to handle this scenario?

jeffbski commented 7 years ago

@kimsagro1 thanks for the question.

The way I like to handle these things, my UI events result in simple object actions being dispatched. Then I wire redux-logic up to listen for those actions and perform whatever side effects etc.

So I would have thin action creator which simply dispatches what happened.

function handleSubmit(values) {
  return createEvent(values); // returns the action { type: EVENT, payload: values }
}

Then I would use redux-logic to listen for EVENT actions

const eventLogic = createLogic({
  type: EVENT,
  process({ API, routerActions, action }, dispatch, done) {
    return API.createEvent(action.payload) // POST to server
      .then(result => dispatch(eventSuccess(result))) // dispatch EVENT_SUCCESS action with result
      .then(() => routerActions.push('/'))
      .then(() => done()); // done dispatching
});

If I use the callback version of process hook w/ dispatch and done, then I can make the API call to the server, dispatch the result EVENT_SUCCESS action, and finally change the route.

So basically we have a lean action creator that just returns an action which gets dispatched.

Then we listen for that and perform whatever processing, side effects, and dispatch more actions.

You could certainly do the route change with logic listening for EVENT_SUCCESS, but you wouldn't have to. In my example we don't need any logic for EVENT_SUCCESS, it was all handled in the first EVENT logic.

So EVENT_SUCCESS is just used for a reducer update and thus it could be used from many scenarios.

One is basically shifting what was in the thunks and fat action creators into logic. So the logic listening to EVENT is where that is done.

Personally I would consider change of route and toast notifications to be side effects. (See below for a common definition). So I would normally perform that from my redux-logic either by calling a routeChange helper or by dispatching an event.

I certainly like to keep my API calls clean (free of actions, dispatching, routing), but in my redux-logic is where I integrate things: listen for actions, perform side effects, route changes, and dispatching actions.

I like to keep my UI very simple. Often I can render and use it without everything (routing) being hooked up. So it doesn't even need a router for some usage and unit testing. I just verify that it emits the right actions for the submit. (I still do full integration testing too).

Obviously there are many different ways to approach things, but this seems to work well for me thus far.

You are certainly welcome to mix and match if you prefer one style or another. These can work together just fine, but you'll want to have clear reasons on why to put something here or there. For me I keep each individual piece (API calls, UI, action creators) as simple as possible and tie it all together in redux-logic.

If there are other use cases you'd like help figuring out, just let me know. It's really good to discuss things to learn the pros and cons of various approaches.


Definition for side effect: http://softwareengineering.stackexchange.com/a/40314

A side effect refers simply to the modification of some kind of state - for instance:

Contrary to what some people seem to be saying:

It's really very simple. Side effect = changing something somewhere.

P.S. As commenter benjol points out, several people may be conflating the definition of a side effect with the definition of a pure function, which is a function that is (a) idempotent and (b) has no side-effects. One does not imply the other in general computer science, but functional programming languages will typically tend to enforce both constraints.

erkiesken commented 7 years ago

but in my redux-logic is where I integrate things: listen for actions, perform side effects, route changes, and dispatching actions.

Same here. I'm fine with logic being aware of some UI stuff.

For example I have panel-based UI where each item gets its own panel for viewing and editing/deleting it. When I call an editing action creator I pass a UI config object from that panel that determines the panel state, what mode it is in with config.mode, was there an error with config.error or if the panel is disabled with config.disabled while action is being taken.

Then in item editing logic I'd get the data needed plus panel config from action.payload, first dispatch a changed panel config (now disabled: true for panel to go inactive and not allow more clicks), then call whatever api.editSomething promise-based API, in a success case .then dispatch new config with disabled: false, error: null and any changed data load action so it would update other needed state. In failure case .catch I'd dispatch new config with disabled: false, error: err.msg so panel could show relevant error inline.

This logic is easy to test as well, just pass in a dummy panel config object in payload and also observe if the disabled/error/etc values got set properly in addition to validating mock API calls and returned data change action dispatching.

jeffbski commented 7 years ago

@tehnomaag excellent. Thanks for sharing this. It is great to get insights and ideas on ways to approach things.

tb commented 7 years ago

I am thinking to make my REST API calls redux setup more generic - something like crudFetch.js however the stuff like notifications and redirects in crudResponse.js would need to be done outside of reducer to make it customizable.

Lets say that I can trigger deletePost from post edit or from posts list. I will make the same API and both will update store similar way, however only in the edit view I want to redirect user to posts index.

Recently I found redux-pack#logging-beforeafter and for me its great way to solve this with deletePost(payload, meta) like:

  deletePost(post, { onSuccess: (response) => push('/posts') });

I was thinking to implement it in the logic creator function, but I thought I will ask: Do you think something like that (hooks defined in meta) could become part of redux-logic?

ms88privat commented 7 years ago

@tb I think with redux-saga or redux-logic we have to rethink how we did things before.

Lets say that I can trigger deletePost from post edit or from posts list.

That's the point here. You should not trigger deletePost because it would only delete a post by its definition. Instead trigger clickOnDeleteButtonOfPostEditView or clickOnDeleteButtonOfPostListItem. Or maybe more generic way: clickOnDeleteButton({view: EditPost}) or something similar.

Then the business logic can decide what should happen in a descriptive order:

jeffbski commented 7 years ago

@ms88privat Thanks for answering. Yes, I tend to agree. Working with logic or sagas we tend to approach things in a more descriptive way and more event driven than the typical imperative way from something like thunks or fat action creators.

There are no wrong ways of doing things, but certain ways seem to be more elegant and easier to maintain. Keeping things small and descriptive reacting to the appropriate events seems to be a great way to go.

luq10 commented 6 years ago

@ms88privat @jeffbski This is realy isteresting, but i have one question:

In this example with deleting post - How i can combited action deletePost with specify action deletePostOnViewPostEdit?

// Action creators
const deletePost = (postId) => ({
  type: 'POST/DELETE/START',
  payload: postId,
});

const deletePostOnViewPostEdit = (postId) => ({
  type: 'POST_EDIT_VIEW/POST/DELETE/START',
  payload: postId,
});

// Logics
export const deletePost = createLogic({
  type: 'POST/DELETE/START',

  processOptions: {
    successType: 'POST/DELETE/SUCCESS',
    failType: 'POST/DELETE/ERROR',
  },

  process({Api, action}) {
    const postId = action.payload;

    return Api.deletePost(postId);
  }
});

export const deletePostOnViewPostEdit = createLogic({
  type: 'POST_EDIT_VIEW/POST/DELETE/START',

  process({action}, dispatch, done) {
    const postId = action.payload;

    dispatch(deletePost(postId));

    // I need do this only if 'POST/DELETE/SUCCESS'
    dispatch(addNotification('post.delete.success'));
    dispatch(redirect.push('/posts'));

    // I need do this only if 'POST/DELETE/ERROR'
    dispatch(addNotification('post.delete.error'));

    done();
  }
});

Ofc. i can repeat the logic deletePost in deletePostOnViewPostEdit like this:

export const deletePostOnViewPostEdit = createLogic({
  type: 'POST_EDIT_VIEW/POST/DELETE/START',

  process({Api, action}, dispatch, done) {
    const postId = action.payload;

    return Api.deletePost(postId)
      .then((data) => {
        dispatch({type: 'POST/DELETE/SUCCESS', payload: data});

        dispatch(addNotification('post.delete.success'));
        dispatch(redirect.push('/posts'));
      })
      .catch((err) => {
        dispatch({type: 'POST/DELETE/ERROR', payload: data, error: true});

        dispatch(addNotification('post.delete.error'));
      })
      .then(done);
  }
});

but i don't think that this is a good idea (DRY rule). In this case i don't repeat to many of my code, but i wondering what when my "child logic" (in this example deletePost) will have more code?

Another way can be send callbacks do "child logic" like this:

export const deletePostOnViewPostEdit = createLogic({
  type: 'POST_EDIT_VIEW/POST/DELETE/START',

  process({action}, dispatch, done) {
    const postId = action.payload;

    dispatch(deletePost(postId, {
      onSuccess: () => {
        dispatch(addNotification('post.delete.success'));
        dispatch(redirect.push('/posts'));

        done();
      },
      onError: () => {
        dispatch(addNotification('post.delete.error'));

        done();
      }
    }));
  }
});

But then when i want to delete post and user in the same logic, one after one (let's say: becouse this is the last post from this user) i fall into the callback hell.

export const doSomething = createLogic({
  type: 'DO_SOMETHING',

  process({action}, dispatch, done) {
    const {postId, userId} = action.payload;

    dispatch(deletePost(postId, {
      onSuccess: () => {
        dispatch(deleteUser(userId, {
          onSuccess: () => {
            dispatch(addNotification('post-and-user.delete.success'));

            done();
          },
          onError: () => {
            dispatch(addNotification('user.delete.error'));

            done();
          }
        }));
      },
      onError: () => {
        dispatch(addNotification('post.delete.error'));

        done();
      }
    }));
  }
});

So this can't be a good practice...

I would ask: what is the best practice in this case?