Closed chrisbolin closed 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 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
@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...
aThing
to complete before continuing with an effect, but aThing
won't effect state? If yes, use a function that returns a promise.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")
}
});
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:
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!
thanks @divmain! I'm going to chew it over, and I might turn it into PR for the docs. 🤘
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.
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
Yeah, that's absolutely true!
How can I simply call one synchronous effect from another (synchronous) effect? In my example below, the
reactLevel*
effects call thechangeLevel
effect. I'd like to write it very cleanly, withresetLevelBad
, but that (spoiler) doesn't work.Specifically,
resetLevelBad
sets the state toundefined
for one tick (I know this because mycomputed
functions were erroring), and thenchangeLevel
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.