I'm trying to make a request to get some user info with redux sagas.
so far I have:
function* getUserDetails() {
const userDetails = axios.get('http://localhost:3004/user').then(response => response)
yield put({ type: 'USER_DATA_RECEIVED', user: userDetails})
}
function* actionWatcher() {
yield takeLatest('GET_USER_DATA', getUserDetails)
}
export default function* rootSaga() {
yield all([
actionWatcher(),
]);
}
but when I log that out user either comes back as undefined or Promise<pending>. so I tried to add in yield call(axios stuff in here)
but that didn't seem to work either
anyone got any ideas either a) how to use call properly? and b) how to pass through a payload with the action?
The correct way to use the call effect in your case would be this:
function* getUserDetails() {
const userDetails = yield call(axios.get, 'http://localhost:3004/user');
yield put({ type: 'USER_DATA_RECEIVED', user: userDetails})
}
The first argument for call is the function you want to call, subsequent arguments are arguments you want to pass to the called function.
Improved Version
Calls to external APIs can always go wrong, so it's a good practice to safeguard against this by wrapping a try/catch block around the Axios call.
In the catch block, you could, for example, dispatch an action that signals an error, which you can use to show an error message to the user.
function* getUserDetails() {
let userDetails;
try {
userDetails = yield call(axios.get, 'http://localhost:3004/user');
} catch (error) {
yield put({ type: 'USER_DATA_ERROR', error });
return;
}
yield put({ type: 'USER_DATA_RECEIVED', user: userDetails})
}
Related
I have issues with yield all in saga effect, I provide my sample code below
function* fetchData(item) {
try {
const data = yield call(request, url);
yield put(fetchDataSuccess(data));
} catch (error) {
yield put(fetchDataFailure(error));
throw error;
}
}
function* fetchSummary(action) {
try {
yield all(
list.map(item=>
call(fetchData, item)
)
);
} catch (error) {
yield put(
enqueueSnackbar({
message: "Has Error",
options: { variant: "error" }
})
);
}
}
The logic of it is that I want to call multiple requests (some success, and some failed).
Expected: If it has failed request, the error will be caught after yield all but those success requests still continue and it should dispatch action "fetchDataSuccess" after individual success request (Promise.all can do this)
Actual: If it has failed request, the error will be caught after yield all, and then saga immediately cancel all other "fetchData" call.
Can anyone help me to achieve this logic. Thanks in advance.
The "Actual" behavior that you are describing fits with what I am seeing in your code. As soon as any error is thrown, we leave the try block and enter the catch block.
When we yield an array of effects, the generator is blocked until all the effects are resolved or as soon as one is rejected (just like how Promise.all behaves). - docs
If you want each fetch to execute then you would need to put the try/catch inside the .map. You can either map to an array of true/false values or set a value on error. Or if you don't mind having multiple snackbars you could put enqueueSnackbar inside fetchData instead of in fetchSummary.
Here's one way to do it:
// modified to return either true or false
function* fetchData(item) {
try {
const data = yield call(request, item);
yield put(fetchDataSuccess({ item, data }));
return true;
} catch (error) {
yield put(fetchDataFailure({ item, error }));
return false;
}
}
function* fetchSummary(action) {
const results = yield all(
action.payload.list.map((item) => call(fetchData, item))
);
// check if any of the results were false;
const hasError = results.some((res) => !res);
if (hasError) {
yield put(
enqueueSnackbar({
message: "Has Error",
options: { variant: "error" }
})
);
}
}
Code Sandbox Demo
I have an action creator which is being watched like this:
yield takeLatest('actionCreator', actionFlow);
in file X.
Inside action flow there's an api call and another dispatch to store this data into the store.
Then, from file Y, I have a saga, and I want to call this actionCreator.
So I go:
yield put({ type: 'actionCreator', etc.. });
However, after this, I do yield select(selector) where selector selects this data and it returns null (as it was before the api call.
Is there a way to wait for that "actionFlow" to end?
File X:
export const aCreator= ({ data, type }) => ({
type: ACTION_A,
payload: { data, type },
});
function* aFlow() {
//api call
//put
}
export default function*() {
yield takeLatest(ACTION_A, aFlow);
}
File Y
import { aCreator } from 'fileX';
function* bFlow(){
yield put(aCreator({ data }));
const something = yield select(selector); //this should return the data saved in the reducer by aFlow but return null, as if it didn't wait for aFlow to finish.
}
export default function*() {
yield takeLatest(ACTION_B, bFlow);
}
put is non blocking so it won't wait until the aFlow has finished.
function* bFlow() {
yield put(aCreator({ data }));
const something = yield select(selector); // this will happen immediately
}
You could add a take effect before select, so it will wait until the call from aFlow finished.
function* bFlow() {
yield put(aCreator({ data }));
yield take(dispatched action from aFlow after api call);
const something = yield select(selector); // will wait until aFlow finishes
}
The dispatched action can be actually anything:
function* aFlow() {
//api call
//put
yield put('api_finished');
}
function* bFlow() {
yield put(aCreator({ data }));
yield take('api_finished');
const something = yield select(selector); // will wait until aFlow finishes
}
After looking through some answers to similar questions here, I just can't get my selector to work. Here's my selector.js:
export const getButtonStatus = state => state.buttonStatus;
(That's the entirety of the file. I don't know if I have to import anything into it. Didn't seem like it from looking at other answers I've seen here.)
and here's what I'm where I'm trying to access the selector in my saga:
import { takeLatest, call, put, select } from "redux-saga/effects";
import { getButtonStatus } from "./selector.js";
...
export function* watcherSaga() {
yield takeLatest("get-tweets", workerSaga);
}
function* workerSaga() {
try {
const buttonStatus = yield select(getButtonStatus);
const response = yield call(getTweets(buttonStatus));
const tweets = response.tweets;
yield put({
type: "tweets-received-async",
tweets: tweets,
nextTweeter: response.nextTweeter
});
} catch (error) {
console.log("error = ", error);
yield put({ type: "error", error });
}
}
...
Here's the error I'm receiving:
Error: call: argument of type {context, fn} has undefined or null `fn`
I'm new to Saga. Can anyone tell me what I'm doing wrong?
The error is not with your selector but with your yield call - it takes the function as an arg followed by the arguments to pass to the function: https://redux-saga.js.org/docs/api/#callfn-args. So it should be:
const response = yield call(getTweets, buttonStatus);
Otherwise looks good!
The Problem
You are probably doing this:
const foo = yield call(bar())
So you don't pass the function itself, but rather the function call.
The Fix
Try to only send the function, not its call.
const foo = yield call(bar)
Notice that we have bar only, not bar().
I have a Saga where I need to do 3 asynchronous requests, then use the responses from the 3 requests in a follow-up request. Here's some psuedo-code to explain:
function* useOtherActionsAndSagas(action) {
try {
const [response1, response2, response3] = yield [
request1,
request2,
request3
];
const orderData = {
...response1,
...response2,
...response3,
};
const response4 = yield request4;
yield put({ type: 'SUCCESS', data: response4 });
} catch (e) {
// handle error
}
The 3 requests request1, request2 and request3 correspond to 3 separate Sagas. For example, for request1 there's a Saga along the lines of:
export function* request1(action) {
try {
const result = yield api.get(`/api/endpoint`);
yield put({...action, type: ACTION1_SUCCESS, data: result});
} catch (e) {
yield put({...action, type: ACTION1_FAIL, errors: e});
}
}
function* watchAction1() {
yield* takeLatest(ACTION1, request1);
}
export default function* () {
yield [fork(watchAction1)];
}
where api.get is a wrapper for Axios.get().
This watcher in that Saga is connected to a corresponding action/reducer.
export const ACTION1 = "actions/ACTION1";
export const ACTION1_SUCCESS = "actions/ACTION1_SUCCESS";
export const ACTION1_FAIL = "actions/ACTION1_FAIL";
const initialState = {
// Initial state
};
export default function reducer(state = initialState, action = {}) {
switch (action.type) {
case ACTION1:
// return state
case ACTION1_SUCCESS:
// return state
case ACTION1_FAIL:
// return state
};
default:
// return state;
}
}
export function request1(data) {
return {type: ACTION1, data};
}
To keep my code DRY I was hoping to take advantage of the existing action and saga in the parent saga. To do this I tried:
const [response1, response2, response3] = yield [
put({type: ACTION1, data: data1}),
put({type: ACTION2, data: data2}),
put({type: ACTION3, data: data3})
];
This correctly initiates each action and their corresponding sagas. However, the response from the requests are not available in the assigned variables. That is, response1, response2 and response3 are references to their actions {type: "actions/ACTION1", data: data1} and not a Promise.
I know it would be possible to duplicate the Axios requests in this parent Saga but I'd lose the bonus of having the success/fail actions for the individual requests.
Is it possible to use a setup like this? If so, how can the responses from the asynchronous requests be retrieved for use in a follow-up request?
If not, what is the correct method for accomplishing this?
Update
I can use the workers from the other Sagas within the parent saga, like this:
import request1 from request1Saga;
const [response1, response2, response3] = yield [
call(request1, data1),
call(request2, data2),
call(request3, data3),
];
where request1, request2 and request3 are the worker functions from other Sagas. That gives the benefit of the ACTION1_SUCCESS and ACTION1_FAIL actions from those Sagas being used.
All you need is combine all combinator with call effect (docs for composing sagas and running tasks in parallel):
const [response1, response2, response3] = yield all([
call(request1),
call(request2),
call(request3)
]);
This will execute sagas in parallel and return results from each of them. It works as Promise.all.
The sagas above (request1 to request3) need to return some data at the end of saga:
export function* request1(action) {
try {
const result = yield call(url => api.get(url), `/api/endpoint`);
yield put({...action, type: ACTION1_SUCCESS, data: result});
// This will be assigned to result1
return result
} catch (e) {
yield put({...action, type: ACTION1_FAIL, errors: e});
}
}
Note: You don't need to fork takeEvery, because it is already "forked":
// Example of root saga:
export default function* () {
yield takeLatest(ACTION1, request1);
yield takeLatest(ACTION2, request2);
// ...
}
How do I test a function inside an if statement or try/catch? For instance,
export function* onFetchMessages(channel) {
yield put(requestMessages())
const channel_name = channel.payload
try {
const response = yield call(fetch,'/api/messages/'+channel_name)
if(response.ok){
const res = yield response.json();
const date = moment().format('lll');
yield put(receiveMessages(res,channel.payload,date))
}
} catch (error){
yield put(rejectMessages(error))
}
}
I need to input a real channel name that actually exist in the database for it to return a valid response for the yields that follow to execute, otherwise it will throw an error. In addition, I will get an error message, cannot read property json of undefined, so the yield after that cannot be reached due to this error message.
So my first problem is 'if(response.ok)' but even if I remove it, yield response.json() would return an error and in addition the yield after that wont be executed.
If anyone can show me how to test these, would be much appreciated.
Pass the response object to the previous execution and test conditional, I would do it like this, hope this helps:
export function* onFetchMessages(channel) {
try {
yield put(requestMessages())
const channel_name = channel.payload
const response = yield call(fetch,'/api/messages/'+channel_name)
if(response.ok){
const res = yield response.json();
const date = moment().format('lll');
yield put(receiveMessages(res,channel.payload,date))
}
} catch (error){
yield put(rejectMessages(error))
}
}
describe('onFetchMessages Saga', () => {
let output = null;
const saga = onFetchMessages(channel); //mock channel somewhere...
it('should put request messages', () => {
output = saga.next().value;
let expected = put(requestMessages()); //make sure you import this dependency
expect(output).toEqual(expected);
});
it('should call fetch...blabla', ()=> {
output = saga.next(channel_name).value; //include channel_name so it is avaiable on the next iteration
let expected = call(fetch,'/api/messages/'+channel_name); //do all the mock you ned for this
expect(output).toEqual(expected);
});
/*here comes you answer*/
it('should take response.ok into the if statemenet', ()=> {
//your json yield is out the redux-saga context so I dont assert it
saga.next(response).value; //same as before, mock it with a ok property, so it is available
output = saga.next(res).value; //assert the put effect
let expected = put(receiveMessages(res,channel.payload,date)); //channel should be mock from previous test
expect(output).toEqual(expected);
});
});
Notice your code probably does more stuff I'm not aware of, but this at least should put u in some line to solve your problem.
You might want to use an helper library for that, such as redux-saga-testing.
Disclaimer: I wrote this library to solve that exact same problem
For your specific example, using Jest (but works the same for Mocha), I would do two things:
First, I would separate the API call to a different function
Then I would use redux-saga-testing to test your logic in a synchronous way:
Here is the code:
import sagaHelper from 'redux-saga-testing';
import { call, put } from 'redux-saga/effects';
import { requestMessages, receiveMessages, rejectMessages } from './my-actions';
const api = url => fetch(url).then(response => {
if (response.ok) {
return response.json();
} else {
throw new Error(response.status); // for example
}
});
function* onFetchMessages(channel) {
try {
yield put(requestMessages())
const channel_name = channel.payload
const res = yield call(api, '/api/messages/'+channel_name)
const date = moment().format('lll');
yield put(receiveMessages(res,channel.payload,date))
} catch (error){
yield put(rejectMessages(error))
}
}
describe('When testing a Saga that throws an error', () => {
const it = sagaHelper(onFetchMessages({ type: 'foo', payload: 'chan1'}));
it('should have called the API first, which will throw an exception', result => {
expect(result).toEqual(call(api, '/api/messages/chan1'));
return new Error('Something went wrong');
});
it('and then trigger an error action with the error message', result => {
expect(result).toEqual(put(rejectMessages('Something went wrong')));
});
});
describe('When testing a Saga and it works fine', () => {
const it = sagaHelper(onFetchMessages({ type: 'foo', payload: 'chan2'}));
it('should have called the API first, which will return some data', result => {
expect(result).toEqual(call(api, '/api/messages/chan2'));
return 'some data';
});
it('and then call the success action with the data returned by the API', result => {
expect(result).toEqual(put(receiveMessages('some data', 'chan2', 'some date')));
// you'll have to find a way to mock the date here'
});
});
You'll find plenty of other examples (more complex ones) on the project's GitHub.
Here's a related question: in the redux-saga docs, they have examples where take is listening for multiple actions. Based on this, I wrote an auth saga that looks more or less like this (you may recognize that this is a modified version of an example from the redux-saga docs:
function* mySaga() {
while (true) {
const initialAction = yield take (['AUTH__LOGIN','AUTH__LOGOUT']);
if (initialAction.type === 'AUTH__LOGIN') {
const authTask = yield fork(doLogin);
const action = yield take(['AUTH__LOGOUT', 'AUTH__LOGIN_FAIL']);
if (action.type === 'AUTH__LOGOUT') {
yield cancel(authTask);
yield call (unauthorizeWithRemoteServer)
}
} else {
yield call (unauthorizeWithRemoteServer)
}
}
}
I don't think this is an anti-pattern when dealing with Sagas, and the code certainly runs as expected outside the test environment (Jest). However, I see no way to handle the if statements in this context. How is this supposed to work?