I have a saga:
function* mySaga() {
try {
yield put({type: 'ACTION_TYPE'})
} catch (e) {
handleError(e)
}
}
If the reducer that handles ACTION_TYPE throws an error, the catch block won't run. At least not within my tests.
I have set up a Redux middleware to catch all errors and then dispatch an action that can update the store accordingly.
const errorHandlerMiddleware = store => next => action => {
try {
return next(action)
} catch (err) {
return next({
type: 'START_ERROR_STATE',
err: err
})
}
}
But if I have another saga that puts two sequential actions:
function* anotherSaga() {
try {
yield put({type: 'ACTION_TYPE'})
yield put({type: 'ANOTHER_ACTION_TYPE'})
} catch (e) {
handleError(e)
}
}
Even if the reducer that handles the action of type ACTION_TYPE throws an error, anotherSaga will still dispatch ANOTHER_ACTION_TYPE. I don't want this to happen.
I have implemented a race:
function* raceSaga() {
const { error} = yield race({
main: call(anotherSaga),
error: take('START_ERROR_STATE')
})
if (main) {
yield put({type: 'SUCCESS_ACTION'})
} else if (error) {
yield put({type: 'ERROR_ACTION'})
}
}
I'm not sure if it's the best approach.
There is something wrong with your redux setup. Citing redux documentation:
The reducer is a pure function that takes the previous state and an action, and returns the next state
Which means that you can't throw errors inside reducers. It should take state, action object and return you next state. I suggest that you revisit redux documantation or some tutorial.
Related
Let's say I have a modal with a button that dispatches an action if you click on it. And I'd like to know the result of the action dispatched: e.g. if it was successful I'll close the modal and to something different otherwise.
With redux-thunk my action would look something like:
export const deleteObjects = () => {
return (dispatch, getState) => {
try {
...
dispatch(setObjects([]));
return true
} catch (e) {
return false
}
}
};
so I could use the result in my component. But how to do the same with redux-sagas? As far as I know, you can use sagas with watchers.
One solution I could think of is to pass a callback function to the action creator and call it inside of saga. Like this:
export const deleteObjects = (callback) => ({
type: DELETE_OBJECTS,
callback
});
export function* deleteObjectsAsync({callback}) {
try {
...
put(setObjects([]))
yield call(callback, true)
} catch (err) {
yield call(callback, false)
}
}
function* watchGetPlaces() {
yield takeEvery(DELETE_OBJECTS, deleteObjectsAsync)
}
Is this a valid solution or there is a more adequate way to do so?
I do not recommend your proposed solution. Passing callback functions is one of the precise things redux-saga tries to prevent the developer to have to deal with.
One clean solution is to wrap your modal closing functionality into its own saga that can be invoked by an action. I'm not sure how you open your modals, but on our apps we dispatch actions to open and close modals. Doing this enables connected components and sagas can manipulate any modal. Sagas are designed to handle side effects. Closing a modal is a side effect. Therefore, a saga is a perfect place to put closing modal logic.
Check out this boilerplate:
export const closeModal = () => ({
type: CLOSE_MODAL,
});
function* onCloseModal() {
// Your logic for closing modals goes here
}
function* closeModalSaga() {
yield takeEvery(CLOSE_MODAL, onCloseModal)
}
export const deleteObjects = () => ({
type: DELETE_OBJECTS,
});
export function* deleteObjectsAsync() {
try {
...
yield put(setObjects([]))
yield put(closeModal());
} catch (err) {
// Your "do-something-else" logic here
// I'd recommend dispatching another action
}
}
function* watchGetPlaces() {
yield takeEvery(DELETE_OBJECTS, deleteObjectsAsync)
}
How to handle firebase auth state observer in redux saga?
firebase.auth().onAuthStateChanged((user) => {
});
I want to run APP_START saga when my app starts which will run firebase.auth().onAuthStateChanged observer and will run other sagas depending on the callback.
As I understand eventChannel is right way to do it. But I don't understand how to make it work with firebase.auth().onAuthStateChanged.
Can someone show how to put firebase.auth().onAuthStateChanged in to eventChannel?
You can use eventChannel. Here is an example code:
function getAuthChannel() {
if (!this.authChannel) {
this.authChannel = eventChannel(emit => {
const unsubscribe = firebase.auth().onAuthStateChanged(user => emit({ user }));
return unsubscribe;
});
}
return this.authChannel;
}
function* watchForFirebaseAuth() {
...
// This is where you wait for a callback from firebase
const channel = yield call(getAuthChannel);
const result = yield take(channel);
// result is what you pass to the emit function. In this case, it's an object like { user: { name: 'xyz' } }
...
}
When you are done, you can close the channel using this.authChannel.close().
Create your own function onAuthStateChanged() that will return a Promise
function onAuthStateChanged() {
return new Promise((resolve, reject) => {
firebase.auth().onAuthStateChanged((user) => {
if (user) {
resolve(user);
} else {
reject(new Error('Ops!'));
}
});
});
}
Then use call method to get the user synchronously
const user = yield call(onAuthStateChanged);
This could be handled in the Saga such as the following for Redux Saga Firebase:
// Redux Saga: Firebase Auth Channel
export function* firebaseAuthChannelSaga() {
try {
// Auth Channel (Events Emit On Login And Logout)
const authChannel = yield call(reduxSagaFirebase.auth.channel);
while (true) {
const { user } = yield take(authChannel);
// Check If User Exists
if (user) {
// Redux: Login Success
yield put(loginSuccess(user));
}
else {
// Redux: Logout Success
yield put(logoutSuccess());
}
}
}
catch (error) {
console.log(error);
}
};
here is how you would run the onAuthStateChanged observable using redux-saga features (mainly eventChannel)
import { eventChannel } from "redux-saga";
import { take, call } from "redux-saga/effects";
const authStateChannel = function () {
return eventChannel((emit) => {
const unsubscribe = firebase.auth().onAuthStateChanged(
(doc) => emit({ doc }),
(error) => emit({ error })
);
return unsubscribe;
});
};
export const onAuthStateChanged = function* () {
const channel = yield call(authStateChannel);
while (true) {
const { doc, error } = yield take(channel);
if (error) {
// handle error
} else {
if (doc) {
// user has signed in, use `doc.toJSON()` to check
} else {
// user has signed out
}
}
}
};
please note that other solutions that don't utilize channel sagas are not optimal for redux-saga, because turning an observable into a promise is not a valid solution in this case since you would need to call the promise each time you anticipate a change in authentication state (like for example: taking every USER_SIGNED_IN action and calling the "promisified" observable), which will negate the whole purpose of an observable
We have a general Form component, whith an accompanying saga to handle validation and submission
function* validateAndSubmit(action) {
const errors = clientSideValidate(action.values);
if (errors) {
return yield put({type: SUBMIT_FAILED, formKey: action.formKey, errors: errors});
}
try {
const response = yield call(submitToTargetUrl(action.values, action.url));
if (response.errors) {
return yield put({type: SUBMIT_FAILED, formKey: action.formKey, errors: response.errors});
}
yield put({type: SUBMIT_SUCCESS, formKey: action.formKey});
} catch (e) {
yield put({type: SUBMIT_FAILED, formKey: action.formKey, errors: [e.message]});
}
}
function* form() {
yield takeEvery(SUBMITTED, validateAndSubmit);
}
Now, we have another component, say UserForm that wraps the general Form component. On submission, we want to Submit the form to the backend and fetch some data from an external API concurrently, wait for both to complete, and then dispatch some action. This logic will live in another Saga in some other file. What would be the right pattern for reusing the validateAndSubmit logic? Is there some way to do this:
function* handleUserFormSubmit(action) {
const [submitResult, fetchResult] = yield all([
call(validateAndSubmitSaga),
call(fetchOtherData),
]);
// ...test for successful results for both
if (allIsGood) {
yield put({type: ALL_IS_GOOD});
}
}
function* userForm() {
yield takeEvery(USER_FORM_SUBMITTED, handleUserFormSubmit);
}
Thanks!
I would suggest to create a reusable validateAndSubmit function that would handle the validation and submission, then would return an error if any. And then, have a form submit saga effect that uses this function.
async function reusableValidateAndSubmit(formValues, submitUrl) {
try {
const errors = clientSideValidate(formValues);
if (errors) {
return errors;
}
const response = await submitToTargetUrl(formValues, submitUrl);
if (response.errors) {
return response.errors;
}
return null;
} catch (e) {
console.error('#reusableValidateAndSubmit: ', e);
return [e.message];
}
}
function* handleFormSubmitSaga(action) {
try {
const { values, url, formKey } = action;
const errors = yield call(reusableValidateAndSubmit, values, url);
if (errors) {
return yield put({type: SUBMIT_FAILED, formKey: formKey, errors: errors});
}
return yield put({type: SUBMIT_SUCCESS, formKey: formKey});
} catch (e) {
return yield put({type: SUBMIT_FAILED, formKey: action.formKey, errors: [e.message]});
}
}
function* form() {
yield takeEvery(SUBMITTED, handleFormSubmitSaga);
}
For the handleUserFormSubmit, I'm not just quite sure if in your use case, you want for the validateAndSubmitSaga to fail if fetchOtherData fails, and vice versa. Using redux-saga's all() would bring this effect as it behaves like Promise.all().
A snippet on the return value of Promise.all() from MDN:
This returned promise is then resolved/rejected asynchronously (as soon as the stack is empty) when all the promises in the given iterable have resolved, or if any of the promises reject.
Supposedly, it is your expected behavior, and having implemented the code above. You could just reuse reusableValidateAndSubmit function
function* handleUserFormSubmit(action) {
const [submitError, fetchResult] = yield all([
call(reusableValidateAndSubmit, action.values, action.url),
call(fetchOtherData),
]);
// ...test for successful results for both
// submitError is null if submit was a success
// fetchResult must have a value or return true if was a success
if (!submitError && fetchResult) {
yield put({type: ALL_IS_GOOD});
}
}
May I also suggest having a look at some form frameworks that you could partner with redux (i.e. redux-form) as they could also help in some use cases.
We ended up with a slightly different solution. Instead of call-ing we're take-ing:
function* handleUserFormSubmit(action) {
const [submitResult, fetchResult] = yield all([
yield take(SUBMIT_SUCCESS),
yield take(FETCH_OTHER_DATA_SUCCESS),
]);
// ...test for successful results for both
if (allIsGood) {
yield put({type: ALL_IS_GOOD});
}
}
function* userForm() {
yield takeEvery(USER_FORM_SUBMITTED, handleUserFormSubmit);
}
This way the other Saga can do its thing undisturbed, an this saga can React according to its own logic
I have app which loads 'favorites' each time user logs in. Data are loaded from IndexedDB using localforage. It works perfectly when app is starting fresh (after window refresh). When I logout and login (root saga task running) call that loads 'favorites' data throws error:
TypeError: Cannot read property 'ready' of null
at step:
export function* handleRecoverFavorites() {
try {
const resp = yield call(localforage.getItem, 'favorites')
saga code extract:
export function* handleRecoverFavorites() {
try {
const resp = yield call(localforage.getItem, 'favorites')
if(resp) {
yield put(recoverFavorites(resp))
yield all(resp.map(symbol => put(getTickPricesSubscribe(symbol))));
}
} catch(err) {
let response={"errorDescr":"Error whilst recovering favorites: "+err}
yield put({ type: types.RESPONSE_ERR, response })
console.log(response.errorDescr)
}
}
function* runAtStartupSubscirptions(socket, streamSessionId) {
yield fork(send, {"command": "getBalance", "streamSessionId": streamSessionId }, socket );
yield fork(handleRecoverFavorites)
yield fork(handleRecoverCharts)
while(true) {
yield call(delay, STREAMING_PING_TIME)
yield call(send, {"command": "ping", "streamSessionId": streamSessionId }, socket );
}
}
function* handleRequests(socket) {
let streamSessionId = yield select(state => state.get("auth").get("streamSessionId"))
while(true) {
yield take(types.STREAMING_SOCKET_STATUS)
if(socket.readyState === 1)
yield fork(runAtStartupSubscirptions, socket, streamSessionId)
}
}
export function* handleStreamingConnection() {
let server = yield select(state => state.get("auth").get("server"))
const socket = yield call(createWebSocketConnection, server+"Stream" )
const socketChannel = yield call(createSocketChannel, socket, null)
const task = yield fork(handleRequests, socket)
let channelMsg;
do {
channelMsg = yield take(socketChannel)
if(channelMsg.socketResponse) {
const response = channelMsg.socketResponse;
switch (response.command) {
case "candle":
yield put({ type: types.GET_CANDLES_STREAMING, response })
break;
(...)
default:
console.log("unrequested data: "+response)
}
}
if(channelMsg.socketStatus) {
console.log(channelMsg)
yield put({ type: types.STREAMING_SOCKET_STATUS, channelMsg })
}
} while (channelMsg.socketStatus!=="Disconnected")
yield cancel(task)
}
export default function* rootSaga() {
while(true) {
// Wait for log-in
yield take(types.LOGIN_SUCCESS);
const handleStreamingtask = yield fork(handleStreamingConnection)
yield take([types.LOGOUT_REQUEST, types.RECONNECTING ])
yield cancel(handleStreamingtask)
const channelMsg={"socketStatus" : "Disconnected"}
yield put({ type: types.STREAMING_SOCKET_STATUS, channelMsg })
}
}
I will be appreciated for any suggestions.
I have added console.log line to see result without saga call:
export function* handleRecoverFavorites() {
try {
console.log(localforage.getItem('favorites'))
const resp = yield call(localforage.getItem, 'favorites')
it returns every time Promise with correct value:
Promise {<pending>}
__proto__:Promise
[[PromiseStatus]]:"resolved"
[[PromiseValue]]:Array(1)
0:"CHFPLN"
length:1
This is how I solved it, instead of direct call of localforage method inside "yield call", I have wraped it into separate class:
export class LocalDataService {
getData = ( param ) => {
return localforage.getItem(param)
.then( result => result)
.catch( reason => reason)
}
}
export function* handleRecoverFavorites() {
try {
const api = new LocalDataService()
const resp = yield call( api.getData, 'favorites')
(...)
As per Sylwek reply, and investigating a bit further, the issue relies on the internal setItem context not binding as we expected.
By calling localforage with the following form, we can avoid creating a class:
yield call([localforage, localforage.getItem], 'favorites')
This fixed it for me :)
Check the this context section in saga docs: https://redux-saga.js.org/docs/basics/DeclarativeEffects/
Is there a way in redux saga to dispatch an action from a function that is being called by call effect to the global store?
for example:
export function* login({ payload }) {
try {
// from API.post I want to be able to dispatch an action that I will handle in a different saga.
const resp = yield call(API.post, 'api/1/login', {
email: payload.email,
password: payload.password,
});
yield put(AuthActions.loginSuccess(resp.token));
} catch (error) {
yield put(AuthActions.loginError(error));
}
}
I know that I can use saga's channel, but if I pass channel to the API.post I must use take(channel) and I want to handle this action in a different file so I dont have access to this channel. And I also not sure I want to export the channel and import it in a different file cause I want each saga to be independent.
What can I do?
One possibility is to create a wrapper function for API calls. Like this:
// `apiRequest` dispatches `anotherAction` and calls given `apiCall`
function* apiRequest(apiCall, ...args) {
yield put(anotherAction())
yield call(apiCall, ...arg)
}
export function* login({ payload }) {
try {
const resp = yield apiRequest(API.post, 'api/1/login', {
email: payload.email,
password: payload.password,
})
yield put(AuthActions.loginSuccess(resp.token))
} catch (error) {
yield put(AuthActions.loginError(error))
}
}