Learning Redux-Sagas Together
@vjustov
Basics
How we use it
Q & A
What is redux-sagas?
"What's a middleware?" - I hear you ask
A middleware is some code you can put between dispatching an action, and the moment it reaches the reducer
"And a Side effect?"
Asynchronous things like data fetching and impure functions like accessing the browser cache.
firing tracking events, setting up a subscription, and manually changing the DOM in React components are all examples of side effects.
But can't I do that right now?
and making the call inside component?
class App extends Component {
constructor() {
super();
this.state = { data: [] };
}
componentDidMount() {
fetch(`https://api.coinmarketcap.com/v1/ticker/?limit=10`)
.then(res => res.json())
.then(json => this.props.requestSuccess(json));
.catch(e => {
switch (e.response.status) {
case 409: {}
default: {}
}
this.props.requestFailed(e)
})
}
render() {
return (<p>{this.props.data}</p>);
}
}
Isn't this exactly what solves?
Redux-Effects
Redux-Observable
Redux-Thunk
Redux-Loop
Based on some readings and my experience...
Use Thunk instead of Saga for simple and trivial tasks like:
- AJAX calls
- data polling and only if they are started directly by the user interaction.
Use Saga for
- intertwined tasks, the login example of the docs is perfect
- flows with a lot of steps and waitings for other conditions to happen ("finite-state machine" flows)
- tasks that work in the background and proceed independently from the user interaction (or a mix of background/interactions)
But how do sagas work?
Cue the function
generator *
They use Iterators
an iterator object for the function is returned instead. When the iterator's next() method is called, the generator function's body is executed until the first yield expression
Iterators can pass values
function* anotherGenerator() {
const string = "some code"
const who = "world"
yield who
string.concat(" more code")
}
const gen = anotherGenerator().next()
const gretee = gen.next().value
`hello, ${gretee}`
=> hello, world
And also receive them
const gen = translationGenerator().next()
const translation = gen.next("lapiz")
translation.value
=> "stift"
function* translatorGenerator() {
const spanishWord = yield;
return dictionary[spanishWord];
}
export function* fetchData() {
try {
const data = yield axios.get('/user?ID=12345')
yield dispatch({type: "FETCH_SUCCEEDED", data})
} catch (error) {
yield dispatch({type: "FETCH_FAILED", error})
}
}
function thunkActionCreator(forPerson) {
return function(dispatch) {
return axios.get('/user?ID=12345').then(
(data) => dispatch({type: "FETCH_SUCCEEDED", data}),
(error) => dispatch({type: "FETCH_FAILED", error}),
);
};
}
Remember what we said about generators being unable to restart themselves?
function middleware(store) {
return next => action => {
const result = next(action)
const iter = saga(action)
process(iter)
return result
}
}
function process(iterator) {
function iterate(arg) {
const yielded = iterator.next(arg)
if (!yielded.done) {
digestEffect(result.value, iterate)
}
}
iterate()
}
Yeah, that doesn't tell me much...
function digestEffect(
effect,
nextCallback /* iterate() */
) {
if (is.simpleValue(effect)) {
nextCallback(effect)
}
else if (is.promise(effect)) {
effect.then(nextCallback)
}
}
Effects
The other nice thing Sagas provide.
takeLatest("EXAMPLE", ()=>{})
{
// ...
type: 'FORK',
payload: {
fn: ƒ takeLatest(patternOrChannel, worker),
args: ['EXAMPLE', () => {}]
}
};
call(getLoggedInDetails)
{
// ...
type: 'CALL',
payload: {
context: null,
fn: f getloginDetails(),
args: []
}
};
put(getLoggedInDetails())
{
// ...
type: 'PUT',
payload: {
action: {
type: 'app/GET_LOGGED_IN_DETAILS_IN_PROGRESS'
}
}
};
- take(...)
- takeEvery(...)
- takeLatest(...)
- put(...)
- call(...)
- select(...)
- fork(...)
- cancel(...)
Let's digest those effects.
function digestEffect(
effect,
nextCallback /* iterate() */
) {
if (is.simpleValue(effect)) {
nextCallback(effect)
}
else if (is.promise(effect)) {
effect.then(nextCallback)
}
else if (is.effectObj(effect)) {
const effectRunner = effectRunnerMap[effect.type]
effectRunner(effect.payload, nextCallback)
} else if (is.iterator(effect)) {
// The process function from before
process(effect)
}
}
export function* fetchData() {
try {
const data = yield call(axios.get, '/user?ID=12345')
yield put({type: "FETCH_SUCCEEDED", data})
} catch (error) {
yield put({type: "FETCH_FAILED", error})
}
}
This makes testing easier
export function* fetchData(action) {
const data = yield call(callApi, action.payload.url)
}
const gen = fetchData()
assert.deepEqual(
gen.next().value,
{
CALL: {
fn: Api.fetch,
args: ['./products']
}
},
'it should call the fetch api'
);
Root Saga and watchers
export default function* rootSaga() {
yield fork(watchProductSaga)
yield fork(watchProductContentSaga)
yield fork(watchProductReviewsSaga)
yield fork(watchProductStockSaga)
yield fork(watchUserIdentityIdSaga)
yield fork(watchUserAddToBasketSaga)
yield fork(watchFactFinderSaga)
yield fork(watchLocationChange)
yield fork(watchBrandPageDetails)
yield fork(watchCampaignPageDetails)
yield fork(watchCategoryPageDetails)
yield fork(watchTvShowSaga)
yield fork(watchStockReminderSaga)
}
Watcher/Worker
refers to a way of organizing the control flow using two separate Sagas
-
The watcher: will watch for dispatched actions and fork a worker on every action
-
The worker: will handle the action and terminate
function* watchFetchData() {
yield takeEvery(
'FETCH_REQUESTED',
fetchData
)
}
function* fetchData(action) {
try {
const data = yield axios.get('/user?ID=' + action.payload)
yield dispatch(requestSuccessfull(data))
} catch (error) {
yield dispatch(requestFailed(error))
}
}
Back to the Middleware
A channel is an object used to send and receive messages between tasks. Messages from senders are queued until an interested receiver request a message, and registered receiver is queued until a message is available.
function sagaMiddleware() {
return function middleware(store) {
return next => action => {
const result = next(action)
channel.put(action)
const iter = saga(action)
process(iter)
return result
}
}
}
function process(iterator) {
function iterate(arg) {
const yielded = iterator.next(arg)
if (!yielded.done) {
digestEffect(result.value, iterate)
}
}
iterate()
}
Fork effect runner
fork, like call, can be used to invoke both normal and Generator functions. But, the calls are non-blocking, the middleware doesn't suspend the Generator while waiting for the result of fn. Instead as soon as fn is invoked, the Generator resumes immediately.
function runForkEffect(fn, ...args, cb) {
const taskIterator = createTaskIterator(fn, args)
const child = process(taskIterator)
cb(child)
}
Take effect runner
Creates an Effect description that instructs the middleware to wait for a specified action on the Store. The Generator is suspended until an action that matches pattern is dispatched.
The result of yield take(pattern) is an action object being dispatched.
function runTakeEffect(channel, pattern, cb) {
try {
channel.take(cb, pattern)
} catch (err) {
cb(err, true)
return
}
}
Basics
How we use it
Q & A
Api Calls
We extract api calls into their own self-contained sagas
Orchestrator Sagas
Handle Logic by composing a lot of different smaller sagas.
export function* loginAndRedirectUser(action) {
const {
username, password, url
} = action.payload;
yield put(
loginUser(username, password)
);
yield take(
actionTypes.REQUEST_SUCCESS
);
yield put(getLoggedInDetails());
updateLoginState();
yield url.redirect()
}
How we test
describe(sagaWatcher, () => {
it('listens the EXAMPLE ActionType', () => {
const iterator = sagaWatcher();
const actualYield = iterator.next().value;
expect(actualYield.type).toEqual('FORK');
expect(actualYield.payload.args).toEqual(
expect.arrayContaining(['EXAMPLE_ACTION_TYPE', saga])
);
});
});
describe(saga, () => {
const iterator = loginAndRedirectUser({type: "EXAMPLE"});
const effects = effectsFrom(iterator);
it('logs in the user', async () => {
expect(effects).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'PUT',
payload: expect.objectContaining({
action: expect.objectContaining({
type: "ANOTHER_ACTION_TYPE"
})
})
})
])
);
});
});
Going Crazy
Fancy
Races
Let's wait for the first thing to happen of the following list
export function* trackLoginResult(loginMethod) {
const winner = yield race({
success: take(LOGIN_USER_REQUEST_SUCCESS),
error: take(LOGIN_USER_REQUEST_FAILED)
});
if (winner.success) {
const loginSuccess = {
loginMethod,
...winner.success.payload
};
yield put(trackLoginSuccess(loginSuccess));
} else {
const loginFailedReason = winner.error.reason;
const loginFailedDetailed = {
loginFailedReason,
loginMethod
};
yield put(trackLoginFailure(loginFailedDetailed));
}
}
Dont be afraid of Take()
And While(true)
Queuing up GTM Events
Basically, Queues tracking events to be dispatched only after a pageImpression is tracked.
export function* trackingQueue() {
yield take(LOCATION_CHANGE);
while (true) {
const chn = yield actionChannel(
isTrackingAction
);
const impression = yield take(
TRACK_PAGE_IMPRESSION
);
yield put(writeToDL(impression));
while (true) {
const winner = yield race({
locationChanged: take(
LOCATION_CHANGE
),
trackingAction: take(chn)
});
if (winner.trackingAction) {
yield put(writeToDL(
winner.trackingAction
));
} else {
break;
}
}
}
}
Basics
How we use it
Q & A
Returning effects from classes?
Should our classes know about saga effects?
export class RedirectUrl {
//...
redirect(): CallEffect | PutEffect {
if (this.isAbsolute()) {
return call(redirectTo, this.value);
} else {
return put(push(this.value));
}
}
Thank
You
<3
Learning Redux-Sagas Together
By Viktor Ml. Justo Vasquez
Learning Redux-Sagas Together
- 415