FormidableLabs / freactal

Clean and robust state management for React and React-like libs.
MIT License
1.65k stars 46 forks source link

Calling one effect from another #5

Closed chrisbolin closed 7 years ago

chrisbolin commented 7 years ago

How can I simply call one synchronous effect from another (synchronous) effect? In my example below, the reactLevel* effects call the changeLevel effect. I'd like to write it very cleanly, with resetLevelBad, but that (spoiler) doesn't work.

export default withState({
  ...
  effects: {
    changeLevel: (effects, level) => state => doSomethingInteresting(level),
    resetLevelBad: effects => state => effects.changeLevel(state.level),
    resetLevelGood: effects => state => {
      effects.changeLevel(state.level);
      return state; // why do I have to return state here?
    },
  },
});

Specifically, resetLevelBad sets the state to undefined for one tick (I know this because my computed functions were erroring), and then changeLevel is called and all is well again. Maybe have freactal assume that returning a Promise from an effect means don't alter the state? Or better yet, just wait on that promise.

divmain commented 7 years ago

This is one thing that is not spelled out in the docs right now. Let me ping you on Slack and we can work through how to explain this. Thanks for calling this out!

divmain commented 7 years ago

This should now be answered in the guide.

chrisbolin commented 7 years ago

@divmain what would be the freactal idiomatic / best-practice way of writing my resetLevel example? I realize now that what I was after was code-reuse, and the API is designed for stringing together (potentially) async effects

divmain commented 7 years ago

@chrisbolin, it's not 100% clear to me what doSomethingInteresting is supposed to do, and the answer to your question depends on that. I'll try to answer for all scenarios :)

There are several things that can be composed together here:

A clear distinction can be drawn about what to use in what scenario...

Examples

1. Helper function

Let's say that doSomethingInteresting is a helper function that takes in state and a value of some sort, and returns a new state:

const doSomethingInteresting = (state, badOrGood, val) => {
  /* ... */
  return newStateObject;
};
export default provideState({
  // ...
  effects: {
    resetLevelBad: (effects, someValue) => state =>
      doSomethingInteresting(state, "bad", someValue),
    resetLevelGood: (effects, someValue) => state =>
      doSomethingInteresting(state, "good", someValue)
  }
});

And it would be consumed from your component like so:

effects.resetLevelBad("myValue");
// or...
effects.resetLevelGood("myValue");

You could also make it more functional (and arguably more clean) by doing:

const doSomethingInteresting = badOrGood => (effects, val) => state => {
  /* ... */
  return newStateObject;
};
export default provideState({
  // ...
  effects: {
    resetLevelBad: doSomethingInteresting("bad"),
    resetLevelGood: doSomethingInteresting("good")
  }
});

2. A different effect

In this scenario, one effect calls another effect. The first can (but does not need to) wait for the second to complete before continuing.

To clarify any effect that is invoked will result in a (potential) state change. That's the purpose of an effect.

So let's take doSomethingInteresting and the reset* effects out of the equation for now, and just look at what your original example would look like if changeLevel resulted in a state change.

export default provideState({
  // ...
  effects: {
    changeLevel: (effects, level) => state => Object.assign({}, state, { level }),
    // which is equivalent to the following, using the utility function:
    changeLevel: softUpdate((state, level) => ({ level }))
  }
});

Here, whenever changeLevel is invoked, it will result in a state change for the state key level. Consuming this API would look like:

effects.changeLevel("good");
// or
effects.changeLeve("bad");

Now, let's say we really want to manage intermediate state. By intermediate state, I mean a state change that occurs between the start and the end of a different effect. Maybe level indicates that data is dirty, or that an API request is in progress (so you can show a spinner or something).

To go with that example, let's define a piece of state called numRequestsInProgress. If numRequestsInProgress > 0, we'll show a spinner. If it is 0, we show our normal UI.

export default provideState({
  initialState: () => ({
    numRequestsInProgress: 0,
    userInfo: null,
    posts: null
  }),
  effects: {
    startRequest: softUpdate(state => ({ numRequestsInProgress: state.numRequestsInProgress + 1 })),
    endRequest: softUpdate(state => ({ numRequestsInProgress: state.numRequestsInProgress - 1 })),
    updateUserInfo: effects => effects.startRequest()
      .then(() => fetch("http://some-url"))
      .then(response => response.json())
      .then(({ userInfo }) =>
        effects.endRequest().then(() =>
          state => Object.assign({}, state, { userInfo })
        )
      ),
    updateUserPosts: effects => effects.startRequest()
      .then(() => fetch("http://some-other-url"))
      .then(response => response.json())
      .then(({ posts }) =>
        effects.endRequest().then(() =>
          state => Object.assign({}, state, { posts })
        )
      )
  }
});

If you're unfamiliar with Promise composition, this might look like magic, so I'll go through updateUserInfo line-by-line.

Every effect is defined as a function that takes an effects argument and one or more purpose-specific arguments. It returns either:

  1. a function that takes in old state and returns new state, or
  2. a promise that resolves to #1.

With that said, let's make sure this is true in the example:

/* 1 */ updateUserInfo: effects => effects.startRequest()
/* 2 */   .then(() => fetch("http://some-url"))
/* 3 */   .then(response => response.json())
/* 4 */   .then(({ userInfo }) =>
/* 5 */     effects.endRequest().then(() =>
/* 6 */       state => Object.assign({}, state, { userInfo })
/* 7 */     )
/* 8 */   )

Line 1: updateUserInfo is a function that takes in an effect arg and returns a Promise. This promise needs to resolve to a function (see line 6).

The effect starts out by invoking effects.startRequest. This results in a state change where 1 is added to state.numRequestsInProgress. effects.startRequest returns a Promise.

Line 2: After startRequest resolves, we fetch data from the API. fetch returns a Promise. When a Promise is returned from the then function of a Promise, the outer Promise waits for the inner to resolve before resolving itself. I.e.

const inner = new Promise(resolve => setTimeout(resolve, 1000));
const outer = Promise.resolve().then(() => inner);
outer.then(() => console.log("this is printed after 1 second"))

Line 3: A fetch returns a Response object which has a json method. This json method returns a Promise that resolves to an object. So the outer promise waits for response.json() to resolve before continuing.

Line 4: json method resolves to the object that we see here as the function's argument. I'm destructuring the JSON payload and extracting its userInfo value. The return value for this function is defined on the next line.

Line 5: Here we're invoking effects.endRequest. This function returns a Promise. So once again, the outer promise will wait for the inner to resolve before resolving itself.

endRequest updates state, subtracting 1 from numRequestsInProgress. After doing so, it resolves to the object defined on the next line.

Line 6: We defined an effect as a function that returns a Promise, and that this Promise resolves to a function. That function is what we see here.

This Promise-resolve function is similar in concept to a Redux reducer. It takes in old state and returns new state. So we're updading the userInfo piece of state with the data we fetched from the API.


Now, normally, it would be pretty cumbersome to track all the different API requests that might be in flight to determine whether we should be showing a spinner. Lots of bugs show up in that type of code. Here, however, whether we show a spinner or not is completely declarative, and each piece of the process is testable separately.

In fact, we could even introduce a computed value to make this cleaner:

export default provideState({
  initialState: () => ({ numRequestsInProgress: 0 }),
  computed: {
    showSpinner: ({ numRequestsInProgress }) => numRequestsInProgress > 0
  }
  effects: {
    // ... all the effects we defined in the above example
  }
});

All of this would be consumed from your component like so:

const Component = injectState(({ state, effects }) => {
  const { showSpinner, userInfo, posts } = state;

  return (
    <div>
      {
        showSpinner ?
          <Spinner /> :
          <div>
            <UserInfo userInfo={userInfo} />
            <Posts posts={posts} />
          </div>
      }
      <button onClick={effects.updateUserInfo}>Click to update user info</button>
      <button onClick={effects.updateUserPosts}>Click to update posts</button>
    </div>
  );
});

I hope that helps!

chrisbolin commented 7 years ago

thanks @divmain! I'm going to chew it over, and I might turn it into PR for the docs.  🤘

divmain commented 7 years ago

Sounds great! Also, some of this wasn't working properly until some changes I just published. So if you were encountering issues, it may have simply been bugs :)

I've considered putting together a somewhat complete annotated example app, for learning purposes. Do you think something like that would be a good resource for people jumping in?

Finally, if you have thoughts for improving the docs, I'm super interested! The guide is still in progress, but almost done. And the API docs should quickly follow since they're considerably easier to write up.

chrisbolin commented 7 years ago

I think one of the confusing things is that (A) the effects object as defined by the user (and sent to provideState) is not the same as (B) the effects param given to each effect function.

The difference is that simple synchronous effects are automatically wrapped in promises. At least I think this is the case. I could just be confused :P

divmain commented 7 years ago

Yeah, that's absolutely true!