redux-saga/redux-saga

Question: Integration with redux-form

danturu opened this issue ยท 31 comments

Can't figure out the onSubmit method. Let's say I have the following form:

const formConfig = { form: 'form', fields: ['name',] }

const stateToProps = (state) => {
  return state;
}

const dispatchToProps = (dispatch) => {
  return {
    onSubmit: ({ name }) => {
      // Should return a promise
    }
  }
}

@reduxForm(formConfig, stateToProps, dispatchToProps)
export class Todos extends React.Component {
  render() {
    const { fields: { name }, error, handleSubmit } = this.props;

    return (
      <form  onSubmit={handleSubmit}>
        <input type="text" {...name} />
        <button type="submit">Create</button>
        {error && <div>{error}</div>}
      </form>
    );
  }
}

From the redux-form docs:

If your onSubmit function returns a promise, the submitting property will be set to true until the promise has been resolved or rejected. If it is rejected with an object matching { field1: 'error', field2: 'error' } then the submission errors will be added to each field (to the error prop) just like async validation errors are. If there is an error that is not specific to any field, but applicable to the entire form, you may pass that as if it were the error for a field called _error, and it will be given as the error prop.

The saga could look like:

function* createTodo(action) {
   try {
      const todo = yield call(Api.createTodo, action.name);
      yield put({ type: 'CREATE_TODO_SUCCESS', todo });
   } catch (e) {
      yield put({ type: 'CREATE_TODO_FAILURE', reason: e.toString() });
   }
}

function* saga() {
  yield* takeEvery('CREATE_TODO', createTodo);
}

Then:

onSubmit: ({ name }) => {
  return new Promise((resolve, reject) => {
    dispatch({ type: 'CREATE_TODO', name });      
  });
}

How could I intercept the CREATE_TODO_SUCCESS and CREATE_TODO_FAILURE actions in the promise? I'm really stuck here.

/cc @erikras

A temporary solution could be:

import { stopSubmit } from 'redux-form';

...

onSubmit: ({ name }) => {
  setTimeout(() => dispatch({ type: 'CREATE_TODO', name }));  

  return new Promise(() => {}); // Set the form state to 'SUBMITTING'
}

...

function* createTodo(action) {
   try {
      const todo = yield call(Api.createTodo, action.name);
      yield put({ type: 'CREATE_TODO_SUCCESS', todo });
   } catch (e) {
      yield put({ type: 'CREATE_TODO_FAILURE', reason: e.toString() });
   }
}

function* watchTodo() {
  while (true) {
    yield call(createTodo, yield take('CREATE_TODO'));
  }
}

function* watchForm() {
  while (true) {
    const { failure } = yield race({ 
      success: take(CREATE_TODO_SUCCESS),
      failure: take(CREATE_TODO_FAILURE)
    });

    if (failure) {
      yield put(stopSubmit('form', { _error: failure.reason.message }));
    } else {
      yield put(stopSubmit('form'));
    }
  }
}

function* saga() {
  yield [
    fork(watchTodo),
    fork(watchForm),
  ]
}

I have not fully wrapped my head around redux-saga yet. What sort of API changes from redux-form would make integration with redux-saga easier? e.g. callbacks instead of promises?

@yelouafi Could you please change the label to 'feedback wanted'.

@rosendi
I'm doing this way.
In the form component:

onSubmit: (values) => {
  return new Promise((resolve, reject) => {
    dispatch(someActionCreator({ values, resolve, reject }))
  });
}

In saga:

function* saga() {
  while (true) {
    const { payload: { values, resolve, reject } } = yield take(TYPE)
    // use resolve() or reject() here
  }
}

@tokenvolt brilliant! Thanks so much for sharing that tip, totally solved the problem. You should blog/gist it somewhere. May be do an add-a-note PR here or over at redux-form. It really helps address the interaction between redux-sagas and redux-form.

A 'gotcha' for the solution via @tokenvolt is that connect must be first, (or last depending how you look at it!), in the chain if you are using it. i.e.

export default (connect(
  mapStateToProps, mapDispatchToProps)(
  reduxForm(
    { form: 'loginForm', validate }
  )(LoginPage)));

works great, whereas

export default reduxForm({ form: 'loginForm', validate })(
    connect(mapStateToProps, mapDispatchToProps
   ) ((LoginPage)));

will error on submit asking for a handleSubmit() function.

redux-form wraps and exposes connect(), http://redux-form.com/5.2.5/#/api/reduxForm?_k=myjcva, so you technically don't need to worry about order, but, YMMV.

Ahh right, I see that the older version 5 exposes (or at least replicates connect()). Version 6 doesn't.

@timothyallan really? Interesting, wonder what the new mechanism is, or lack thereof. Thanks for the heads up!

@tokenvolt Thanks, it kinda works. I see the STOP_SUBMIT action.
But how do I pass validation errors?

I tried

reject({ username: 'Username doesn\'t exist' });
yield call(reject, 'Username doesn\'t exist');

None of them worked. (I'm on redux-form 6 alpha)

For now my solution is to use stopSubmit action creator

import { stopSubmit } from 'redux-form';
. . . 
yield put(stopSubmit('Login', { username: 'Username doesn\'t exist', password: 'And your password sucks' }));

Is there a better solution?

Nevermind, I've figured it out.

This is the right way to do it:

import { SubmissionError } from 'redux-form';
. . .
reject(new SubmissionError({ username: 'Username doesn\'t exist', password: 'Please enter your password' }));

// or yield for better testability
yield call(reject, new SubmissionError({ username: 'Username doesn\'t exist', password: 'Please enter your password' }));

Thanks for your comment, it helped a lot!

I've refined the solution of @tokenvolt with the help of a saga that would listen for success/failure actions and resolve/reject a promise returned to the redux-form. The idea being that one needs to just tell the form what action it should dispatch on submit and what actions it should wait for on success or failure.

It's used like that.

export const LoginForm = reduxForm({
  form: 'loginForm',
  fields: ['email', 'password'],
  onSubmit: onSubmitActions(LOGINFORM_SUBMIT, LOGINFORM_SUCCESS, LOGINFORM_FAILURE),
})(LoginFormComponent);
function* loginSaga() {
  const { payload: { email, password } } = yield take(LOGINFORM_SUBMIT);
  // check if the user/pass are correct
  // ....
  if (success) {
    yield put(LOGINFORM_SUCCESS);
  } else {
    yield put(LOGINFORM_FAILURE, { _error: 'Incorrect user/password' });
  }
}

The submit action's payload is the fields object and the payload for the failure action is the field errors object expected from redux-form.

And here is the full onSubmitActions code.

import { put, take, race } from 'redux-saga/effects';
import { takeEvery } from 'redux-saga';

export const FORM_SUBMIT = 'redux-form-submit-actions/FORM_SUBMIT';

export function formSubmit(submitAction, successAction, failureAction, values, resolve, reject) {
  return {
    type: FORM_SUBMIT,
    payload: {
      submitAction,
      successAction,
      failureAction,
      values,
      resolve,
      reject,
    },
  };
}

export function onSubmitActions(submitAction, successAction, failureAction) {
  return (values, dispatch) =>
    new Promise((resolve, reject) => {
      dispatch(formSubmit(submitAction, successAction, failureAction, values, resolve, reject));
    });
}

function* formSubmitSaga({
  payload: {
    submitAction,
    successAction,
    failureAction,
    values,
    resolve,
    reject,
  },
}) {
  yield put({ type: submitAction, payload: { ...values } });

  const { success, failure } = yield race({
    success: take(successAction),
    failure: take(failureAction),
  });

  if (success) {
    resolve();
  } else {
    reject(failure.payload);
  }
}

export function* watchFormSubmitSaga() {
  yield* takeEvery(FORM_SUBMIT, formSubmitSaga);
}

@angelyordanov This works great, thanks :)

Do note that V6 of redux-form expects an instance of SubmissionError for errors now (http://redux-form.com/6.0.0-alpha.15/docs/api/SubmissionError.md/)

e.g.
reject(new SubmissionError(failure.payload));

Using redux-actions, I created this simple action creator to help with @tokenvolt's pattern:

import { identity, noop } from 'lodash'
import { createAction } from 'redux-actions'

const payloadCreator = identity;
const metaCreator = (_, resolve = noop, reject = noop) => ({ resolve, reject });

export const promiseAction = (type) => createAction( type, payloadCreator, metaCreator );

This allows actions to be fired with just a quick:

dispatch(someActionCreator(payload, resolve, reject));

The saga signature then simply looks like:

function* handleSomeAction({ payload, meta: {resolve, reject} }) {

Because there are noop resolve and reject default functions, two things get simpler:

  • The action creator can be used outside of a promise context.
  • The saga is free to blindly call the promise methods

So, calling the action creator without the promise still works:

dispatch(someActionCreator(payload));

function* handleSomeAction({ payload, meta: {resolve, reject} }) {
  try {
    yield call(doTheThing);
    yield call(resolve); 
  } catch (error) {
    yield call(reject, error); 
  }
}

The last piece is a little function that that I called bindActionToPromise (which might be a really sucky name):

export const bindActionToPromise = (dispatch, actionCreator) => payload => {
  return new Promise( (resolve, reject) => dispatch( actionCreator(payload, resolve, reject) ) );
};

The complete pattern then looks like:

const someActionCreator = promiseAction(SOME_ACTION_TYPE);

const mapDispatchToProps = (dispatch) => ({
  onSubmit: bindActionToPromise(dispatch, someActionCreator)
});

function* watchSomeAction() {
  yield* takeEvery(SOME_ACTION_TYPE, handleSomeAction);
}

function* handleSomeAction({ payload, meta: {resolve, reject} }) {
  try {
    yield call(doTheThing);
    yield call(resolve); 
  } catch (error) {
    yield call(reject, error); // Convert to SubmissionError here if needed
  }
}

Hope this helps someone.

Did someone try redux-form-submit-saga lib?

@nktssh I have tried and worked pretty well.

I do not know if these action creators are new or have some downside, but could we not just use the startSubmit and stopSubmit action creators and pass the form name with the submit action?

Example:

function* createEntity(entity, apiFn, formId, newEntity) {
  yield put( entity.request() )
  yield put( startSubmit(formId) )
  const {response, error} = yield call(apiFn, newEntity)
  if(response) {
    yield put( entity.success(response) )
    yield put( reset(formId) )
    yield put( stopSubmit(formId) )
  }
  else {
    yield put( entity.failure(error) )
    // handle and format errors from api
    yield put( stopSubmit(formId, serverValidationErrors) )
  }
}

Quite new to both redux-form and redux-saga, so there could be something i am missing here.

@oanylund
I think thats the issue for redux-form rather than the redux-saga, so it should be issued on their repository.

Agreed. I just came across this issue when i was facing the same challenge and thought i would share the solution i ended up with.

After giving a quick look around this issue, I think the solution by @oanylund is the cleanest possible.
It doesn't requires anything that looks as hacky as passing resolve and reject around.
I already have my form name as a constant, therefore it's so easy to issue startSubmit and stopSubmit actions!

@oanylund but what about validation when clicking 'Submit'?
Some forms could has complex validation flow, and part of this flow must be executed only when user hit 'Submit' button...

Seems like this is not the proper place to discuss this, but to answer shortly...
As i understand startSubmit, it only sets the submitting flag in the form reducer to true and nothing else. I only use it to disable the submit button and set the fields to readonly while submitting. The sync validation happens before this when you click your submit button as always. So if your sync validation is not passing, the action to fire the saga will never be dispatched.

stopSubmit sets the submitting flag to false, and you can also pass a new error object with your server errors to the form.

@nktssh if you want to do client-side validation before you put the submit action, you can mod the answer given by @angelyordanov by tweaking the onSubmitActions function to take a validation function as a parameter (e.g., validateFn), which you invoke before submitting the form. If there are any validation errors, you can call

// validateFn returns an empty object if no errors
// otherwise, an object like { _error: 'errorMessage' }
// where _error is the redux-form key for form-wide errors
const errors = validateFn(values);
if (errors) reject(new SubmissionError(errors))

Here's my variation on @oanylund's answer. I have a module with a utility called formSaga:

import { put, call } from 'redux-saga/effects'
import { startSubmit, stopSubmit, reset, SubmissionError } from 'redux-form'


export default function* formSaga(formId, apiSaga, ...apiSagaArgs) {
  // Start form submit
  yield put(startSubmit(formId))

  try {
    yield call(apiSaga, ...apiSagaArgs)

    // Success

    yield put(reset(formId))
    yield put(stopSubmit(formId))
  } catch (err) {
    if (err instanceof SubmissionError) {
      yield put(stopSubmit(formId, err.errors))
    } else {
      console.error(err) // eslint-disable-line no-console
      yield put(stopSubmit(formId, { _error: err.message }))
    }
  }
}

Example usage: for something like login, I want to be able to login either with an action directly (login/loginSaga), or through a form submission (submitLoginForm/submitLoginFormSaga).

// Actions

export const login = createAction('Login', (email, password) => ({ email, password }))
export const submitLoginForm = createAction('Submit Login Form', (fields) => fields)


// Sagas

export function* loginSaga({ payload: { email, password } }) {
  // call api, etc...
  try {
    yield call(request, { endpoint: '/auth/login', method: 'post', data: { email, password } })
  } catch (e) {
    // could throw a redux-form/SubmissionError here and it'll show up in the view!
    // etc...
  }
}


export function* submitLoginFormSaga({ payload: { email, password } }) {
  // the ...apiSagaArgs in the util keep things pretty generic, but in this case
  // (and most cases) we can just generate a "fake" action using the action creator
  yield call(formSaga, 'login', loginSaga, login(email, password))
}


export function* watcherSaga() {
  yield [
    takeLatest(login.getType(), loginSaga),
    takeLatest(submitLoginForm.getType(), submitLoginFormSaga),
  ]
}

** Might not be idiomatic, this is my first day with redux-saga.

@andrewsuzuki How are you passing values from your form to your saga?

i really think this line of code could be removed: redux-form/redux-form@7e07256#diff-28d8f38ee02f29d2bc406450f6c0d870R27 or at least could be added some option to turn this automatic setSubmitSuccess() off...

i really think this line of code could be removed: redux-form/redux-form@7e07256#diff-28d8f38ee02f29d2bc406450f6c0d870R27 or at least could be added some option to turn this automatic setSubmitSuccess() off...

+1 for new option to not calling stopSubmit and setSubmitSuccess!

Maybe, we can do this as plugin?

alpox commented

I thought that maybe someone would be interested in my solution of this. I built it through the base pattern of @jcheroske - thanks for this!

My solution mainly consists of some helper functions. (I wrote it in Typescript, so you may have to read a bit around type declarations)

I created the same metaCreator for the use with redux-actions:

export function metaCreator() {
    const [resolve, reject] = [...arguments].slice(-2);
    return { resolve, reject };
}

Then I could create the equivalent of createAction of redux-actions which makes use of createAction internally, but makes it return a promise and injects resolve and reject into the meta field of the FSA compilant action:

export const createPromiseAction = (
    actionType: string,
    payloadCreator: (...args: any[]) => any = identity,
) => {
    const action = (...args: any[]) => (
        dispatch: Dispatch<any>,
    ): Promise<any> =>
        new Promise((resolve, reject) => {
            dispatch(
                createAction(actionType, payloadCreator, metaCreator)(
                    ...args,
                    resolve,
                    reject,
                ),
            );
        });

    (action as any)[Symbol.toPrimitive] = () => actionType;
    (action as any).toString = () => actionType;
    return action;
};

Edit: for this to work you need redux-thunk middleware to route the promise through the dispatch call

This can be used simply instead of createAction:

export const login = createPromiseAction(AuthActionType.Login);

In addition, i created another helper - a function which wraps the original saga and catches errors from the original saga and rejects the error through the promise. It calls resolve, when the saga finished its work:

export function formSaga(originalSaga: (...args: any[]) => any) {
    function* newSaga({
        meta: { resolve, reject },
        ...action,
    }: {
        meta: {
            resolve: () => any;
            reject: (err: any) => any;
        };
    }) {
        try {
            yield call(originalSaga, action);
            yield call(resolve);
        } catch (error) {
            yield call(reject, error);
        }
    }

    return newSaga;
}

This can be used like:

export function* authSaga() {
    yield takeEvery(login.toString(), formSaga(loginRequest));
}

Where login is the previously through createPromiseAction created action.

In loginRequest i can then just throw the submission error as i'm used to:

try { ... }
catch (err) {
        yield put(error(AuthActionType.Login, err));

        throw new SubmissionError({
            _error: err.response.data.error.fields || err.response.data.error,
        });
    }

I hope this helps somebody :-) thanks for your help to create this!

Following your suggestions, we have created a simple helper:

export const bindActionToPromise = (dispatch, actionCreator) => payload => {
  return new Promise((resolve, reject) =>
    dispatch(actionCreator(payload, resolve, reject))
  )
}

Then we import it with a simple import { bindActionToPromise } from '../../utils' and use it this way:

const submit = (closePortal, values, dispatch) => {
  const submitUpdate = bindActionToPromise(dispatch, updateSomething.request)
  return submitUpdate({ data: values })
}

In case someone's interested, here's A not-that-simple-but-fully-working-way of handling Redux Forms with Sagas.