Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create Async Action #76

Open
jamescmartinez opened this issue Jan 10, 2019 · 31 comments
Labels

Comments

@jamescmartinez
Copy link

@jamescmartinez jamescmartinez commented Jan 10, 2019

redux-starter-kit includes redux-thunk but doesn't include an async version of createAction. I want to float the idea of a createAsyncAction function that uses thunks; I can create a pull request if all looks good.

Here's a rough initial API proposal:

// To be added:
// (Inspired by the current implementation of `createAction`)
function createAsyncAction(type, thunk) {
  const action = payload => thunk(payload);
  action.toString = () => type;
  return action;
}

// Usage example:
const someAsyncAction = createAsyncAction("SOME_ASYNC_ACTION", payload => {
  return dispatch => {
    return setTimeout(() => {
      // One second later...
      dispatch(someAsyncActionSuccess());
    }, 1000);
  };
});

const someAsyncActionSuccess = createAction("SOME_ASYNC_ACTION_SUCCESS");

The only new proposal is at the top of that snippet, the function createAsyncAction. It takes two parameters: type (same as createAction) and thunk.

What are everyone's thoughts?

@denisw

This comment has been minimized.

Copy link
Contributor

@denisw denisw commented Jan 11, 2019

A helper function for thunk actions sounds like a great idea to me!

I have one thought about the design. In the case of async actions without payload, I could see myself forgetting about specifying the outer function (the one returning the thunk) and directly writing the thunk instead.

// FAIL: I pass a thunk instead of a function returning a thunk
const someAsyncAction = createAsyncAction('SOME_ASYNC_ACTION', dispatch => {
  // ...
})

// I need to remember to do this instead
const someAsyncAction = createAsyncAction('SOME_ASYNC_ACTION', () => dispatch => {
  // ...
})

Not sure how much of a problem it is, especially if you are using TypeScript. But maybe there is a tweak that could be done to mitigate this issue; perhaps throwing a helpful error if no thunk is returned from the passed function. Something along the lines of:

function createAsyncAction(type, thunkCreator) {
  const action = payload => {
    const thunk = thunkCreator(payload);

    if (!(thunk instanceof Function)) {
      throw new TypeError(
        `The function you passed to createAsyncAction('${type}') did not `
        'return a thunk. Did you maybe pass a thunk directly instead of a '
        'function returning a thunk?')
    }

    return thunk;
  };

  action.toString = () => type;
  return action;
}

const someAsyncAction = createAsyncAction('SOME_ASYNC_ACTION', dispatch => {
  // ...
})

someAsyncAction() // TypeError
@markerikson

This comment has been minimized.

Copy link
Collaborator

@markerikson markerikson commented Jan 11, 2019

I'm not sure what the point of this specific proposal is. @jamescmartinez , can you clarify?

@denisw

This comment has been minimized.

Copy link
Contributor

@denisw denisw commented Jan 11, 2019

Oops, I think I had the same confusion as @jamescmartinez when commenting. I had assumed the value-add is to bundle action creator and type together into one object like createAction does. But a thunk action creator doesn't have or need and action type! It just returns a thunk, and that never reaches the reducer (only its dispatched actions do, and those can of course be defined with createAction).

@markerikson

This comment has been minimized.

Copy link
Collaborator

@markerikson markerikson commented Jan 11, 2019

Right, exactly.

The phrase "async action" is confusingly used to refer to two different things:

  • Dispatching a thunk function, which allows you to write async logic while having access to dispatch and getState
  • Defining multiple action types that relate to the lifecycle of AJAX requests, like REQUEST_STARTED, REQUEST_SUCCEEDED, REQUEST_FAILED
@denisw

This comment has been minimized.

Copy link
Contributor

@denisw denisw commented Jan 11, 2019

Yeah. Here is what can already do with the current redux-starter-kit:

const fetchTodosStarted = createAction("todos/fetch/started")
const fetchTodosSucceeded = createAction("todos/fetch/succeeded")
const fetchTodosFailed= createAction("todos/fetch/failed")

const fetchTodos = () => dispatch => {
  return TodosAPI.fetchAll().then(
    todos => dispatch(fetchTodosSucceeded(todos)),
    error => dispatch(fetchTodosFailed(error))
  )
}

Indeed, createAsyncAction() as described in the original issue description wouldn't add anything on top of this. We could alternatively think about making the "thunk + set of async lifecycle actions" pattern more convenient, though. Perhaps something like:

// Example

const fetchTodo = createAsyncAction('todos/fetch', () => {
  return TodosAPI.fetchAll()
})

fetchTodo.started  // equivalent to createAction('todos/fetch:started')
fetchTodos.succeeded  // equivalent to createAction('todos/fetch: succeeded')
fetchTodos.failed  // equivalent to createAction('todos/fetch:failed')
  
// Dispatches fetchTodos.started() immediately, and then
// fetchTodos.succeeded(todos) or fetchTodos.failed(error)
// depending on the promise result.
fetchTodos()
  .then(todos => console.log(`fetchTodos.started was dispatch with: ${todos}`)
  .catch(error => console.log(`fetchTodos.failed was dispatch with: ${error}`)

// Usage with createReducer()

const initialState = {
  todos: null,
  loading: false,
  loadingError: false
}

const reducer = createReducer(initialState, {
  [fetchTodos.started]: state => { 
    state.loading = true
    state.loadingError = false
  },
  [fetchTodos.succeeded]: (state, { payload: todos }) => { 
    state.loading = false
    state.todos = todos
  },
  [fetchTodos.failed]: (state, { payload: error }) => { 
    state.loading = false
    state.loadingError = true
  }
})

// Implementation

function createAsyncAction(type, asyncFn) {
  const started = createAction(`${type}:started`)
  const succeeded = createAction(`${type}:succeeded`)
  const failed = createAction(`${type}:failed`)

  const run = payload => {
    dispatch(started(payload))
    return promiseFn(promise).then(
      result => {
        dispatch(succeeded(result))
        return result
      },
      error => {
        dispatch(failed(error))
        throw error
      }
  )
}
@markerikson

This comment has been minimized.

Copy link
Collaborator

@markerikson markerikson commented Jan 11, 2019

Yeah, there's about a bazillion libraries out there already for generating request action triplets. Question is, is that a pattern that's worth building in to RSK, and if so, how much abstraction should be used?

I actually asked about some of this on Twitter yesterday: https://twitter.com/acemarke/status/1083431099844444161

This also gets off into more complicated questions about how to structure entity data vs "loading state" data (in the items, in the "item type" slice next to the items, or in a totally separate slice for all loading state).

@denisw

This comment has been minimized.

Copy link
Contributor

@denisw denisw commented Jan 11, 2019

True, there are lots of libraries, but as we are already establishing a bunch of conventions through createAction and createReducer, a custom-made version could be made simpler by being opinionated towards these conventions.

I just answered to the Twitter poll. In pretty much every React-Redux project I have participated in within the last three years (about 5), each of them had separate actions of the _STARTED / _SUCCEEDED / _FAILED variety, made a lot of use of _STARTED for loading indicators, and often used _FAILED for showing error messages (at the very least, the developers used them for debugging in devtools).

I don't quite understand your point on entity data vs loading data. Isn't this orthogonal to the very humble goals of a createAsyncAction as specified above?

@markerikson

This comment has been minimized.

Copy link
Collaborator

@markerikson markerikson commented Jan 11, 2019

Right, that's my point. This easily veers off into much more complicated territory.

@denisw

This comment has been minimized.

Copy link
Contributor

@denisw denisw commented Jan 11, 2019

Right, that's my point. This easily veers off into much more complicated territory.

It can, but doesn't have to. By choosing a very narrow scope (codifying the common started/succeeded/failed pattern on top of redux-thunk), I feel it would be possible to offer a useful helper that at the same time leaves full flexibility in all other respects. It's not closing doors - you can still write thunk action creators manually for the cases not covered.

That being said, I understand that redux-starter-kit needs to be very careful about what to include as it will be considered the "official" toolbelt for Redux. I'm also not sure whether such a createAsyncAction should make the cut based on that.

@markerikson

This comment has been minimized.

Copy link
Collaborator

@markerikson markerikson commented Jan 11, 2019

Well, I'm open to suggestions on this topic, and I see the potential value, but I"m also not immediately sure how important it would be to have this included.

@markerikson

This comment has been minimized.

Copy link
Collaborator

@markerikson markerikson commented Jan 12, 2019

An example that looks like it's very related to this discussion: https://medium.com/@ankeet.maini/type-redux-with-typescript-without-writing-any-types-5f96cbfef806

@markerikson

This comment has been minimized.

Copy link
Collaborator

@markerikson markerikson commented Jan 21, 2019

redux-thunk-actions looks like it's basically what I want in terms of generating a thunk that handles an API request.

@drd

This comment has been minimized.

Copy link

@drd drd commented Feb 17, 2019

Yeah. Here is what can already do with the current redux-starter-kit:

const fetchTodosStarted = createAction("todos/fetch/started")
const fetchTodosSucceeded = createAction("todos/fetch/succeeded")
const fetchTodosFailed= createAction("todos/fetch/failed")

const fetchTodos = () => dispatch => {
  return TodosAPI.fetchAll().then(
    todos => dispatch(fetchTodosSucceeded(todos)),
    error => dispatch(fetchTodosFailed(error))
  )
}

My question is how can I integrate this fetchTodos action into the nice structure of redux-starter-kit … in other words, where do I put this, and how do I call it from my component? I can't seem to put normal thunk actions into createReducer, because the function is put into the state by immer. There are no docs that I can find that clarify this seemingly common (and, due to the inclusion of redux-thunk, within the scope of the library) use case. If I'm missing something obvious, please do enlighten me! :)

@markerikson

This comment has been minimized.

Copy link
Collaborator

@markerikson markerikson commented Feb 17, 2019

@drd : you wouldn't use a thunk action creator as a key in createReducer, because A) it doesn't correspond to a single specific action type string, and B) it also doesn't have its toString() overridden anyway.

You would use it as part of your call to connect(), like const mapDispatch = {fetchTodos} and call it as this.props.fetchTodos(), same as always. Nothing about that has changed.

Where you define that function is also entirely up to you.

@drd

This comment has been minimized.

Copy link

@drd drd commented Feb 18, 2019

I figured that out, but only after a frustratingly long debugging session… It seems to me that either redux-starter-kit should have an officially-blessed way to integrate async actions with createReducer / createSlice (e.g., an asyncActions: key) or at least have a clear explanation in the docs that even though this library includes redux-thunk you can't use it with createReducer or createSlice. I suppose that's the thing that ultimately led me astray — I conflated the inclusion of redux-thunk with direct support by these higher-level functions ... I'm happy to contribute code and/or docs to clarify this, but I do think at the moment it's a bit murky!

@markerikson

This comment has been minimized.

Copy link
Collaborator

@markerikson markerikson commented Feb 18, 2019

@drd : that's sort of the point. Side effect approaches have never had a direct correlation to a reducer, because a thunk function or an observable or a saga never directly interacts with a reducer - they just dispatch "normal" actions that a reducer can actually handle.

I'm sorry to hear you were confused, but there's nothing special about RSK in that regard. All that RSK does is add applyMiddleware(thunk) by default so you don't have to do that yourself.

@tim-hm

This comment has been minimized.

Copy link

@tim-hm tim-hm commented Feb 20, 2019

I've stumbled upon this thread after going through a similar learning/discovery process to @drd. I would love for async actions to be closely linked with slices. I appreciate that technically side effects are separate from reducers, but in practice they are closely linked. One library which i feel dealt with this nicely was @rematch where async actions are available on the model. IMO, a slicker dev experience with RSK would follow their example and look like this:

const aSlice = createSlice({
  initialState: { ... },
  reducers: {
     update: (state, action) => action
  },
  slice: { ... },
  effects: (dispatch) => {
     someAsyncAction: async (someValue) => {
        const result = await fetch(...)
        dispatch.aSlice.update(result)
     }
  }
})
@devlead125

This comment has been minimized.

Copy link

@devlead125 devlead125 commented Mar 26, 2019

How about this approach?

const {
  actions: { getByIdReq, getByIdSuccess, getByIdFail }
} = createSlice({
  reducers: {
    getByIdReq(state, action) {},
    getByIdSuccess(state, action) {},
    getByIdFail(state, action) {}
  }
});

export const getById = createAsyncAction(
  [getByIdReq, getByIdSuccess, getByIdFail],
  (payload, rootState) => Api.getById(payload) // You can also access rootState when you need
);

I'm not experienced developer so it maybe wrong implementation but I'd love to introduce implementation idea based on Redux Thunk.

const createAsyncAction = ([req, success, fail], promise) => payload => (
  dispatch,
  getState
) => {
  dispatch(req());
  promise(payload, getState())
    .then(data => dispatch(success(data)))
    .catch(error => dispatch(fail(error)));
};
@SergeyVolynkin

This comment has been minimized.

Copy link

@SergeyVolynkin SergeyVolynkin commented May 20, 2019

@tim-hm's approach is awesome, let's implement that.

Todos example with this approach:

export const todos = createSlice({
  slice: "todos"
  initialState: { ... },
  reducers: {
     set: (state, action) => { ... }
  },
  effects: ({dispatch, getState, actions}) => {
     fetch: async () => {
        const result = await fetch(...)
        dispatch(actions.set(result.todos))
     }
  }
})

P.S.
actions arg in effects are actions that been defined in the current slice

cc @markerikson

@renatoruk

This comment has been minimized.

Copy link

@renatoruk renatoruk commented May 29, 2019

@tim-hm's approach is awesome, let's implement that.

Todos example with this approach:

export const todos = createSlice({
  slice: "todos"
  initialState: { ... },
  reducers: {
     set: (state, action) => { ... }
  },
  effects: ({dispatch, getState, actions}) => {
     fetch: async () => {
        const result = await fetch(...)
        dispatch(actions.set(result.todos))
     }
  }
})

P.S.
actions arg in effects are actions that been defined in the current slice

cc @markerikson

I see how this methodology would benefit even the most trivial projects. Starting with bare redux you very quickly end up having too much boilerplate, especially with typescript. Ducks pattern just delegates that problem to different files, but cohesiveness is almost nonexistent. This way you could directly map your view logic with the model in a reasonable and predictive way. If implemented (fingers crossed), this example should be the first page in redux documentation, in my honest opinion. :) It's just that good.

@Yakimych

This comment has been minimized.

Copy link

@Yakimych Yakimych commented May 29, 2019

Nice! We already put all thunks into an "effects" folder in our projects, and it really helps with an overview of what parts of code are effectful.

One thing that could be tricky with this approach, however, is that thunks don't necessarily belong to a chunk/slice of state (therefore the getState parameter that returns the whole state object). But I suppose the idea is that those can stay "cross-cutting" just as before, while effects that belong to a slice of state can be grouped along with actions and reducers?

@SergeyVolynkin

This comment has been minimized.

Copy link

@SergeyVolynkin SergeyVolynkin commented May 30, 2019

Nice! We already put all thunks into an "effects" folder in our projects, and it really helps with an overview of what parts of code are effectful.

One thing that could be tricky with this approach, however, is that thunks don't necessarily belong to a chunk/slice of state (therefore the getState parameter that returns the whole state object). But I suppose the idea is that those can stay "cross-cutting" just as before, while effects that belong to a slice of state can be grouped along with actions and reducers?

Yes, the proposal is correctly understood. It's an addition rather than an approach change.

@mustafahlvc

This comment has been minimized.

Copy link

@mustafahlvc mustafahlvc commented Jul 11, 2019

I prefer export the slice object directly itself, so there is no need to import every action separately or extra definition in the slice file.

I m using as below as a workaround:

const todos = createSlice({
    slice       : "todos",
    initialState: {...},
    reducers    : {
        set: (state, action) => {
        }
    }
});

_.assign(todos, {
    effects: {
        fetch: () => {
            return (dispatch) => async () => {
                const result = await fetch();
                dispatch(todos.actions.set(result.todos))
            }
        }
    }
});

export default todos;

i could also create a custom createSlice function.

So it would be good to have effects in the slice object.

@quantumpotato

This comment has been minimized.

Copy link

@quantumpotato quantumpotato commented Aug 31, 2019

Bump, this is critical.

@quantumpotato

This comment has been minimized.

Copy link

@quantumpotato quantumpotato commented Aug 31, 2019

What is the "effects"? How do I use this?
@mustafahlvc

@markerikson

This comment has been minimized.

Copy link
Collaborator

@markerikson markerikson commented Aug 31, 2019

@quantumpotato: please provide further details on what exactly you want to do, and what potential API would be useful for you.

@quantumpotato

This comment has been minimized.

Copy link

@quantumpotato quantumpotato commented Aug 31, 2019

I'm wondering two things. One, what is the use of "effects".
Two, I want an example of making http requests through async actions. These asynchronous examples without network activity are contrived for learning the basics and not practical for real world applications.
I am currently looking at https://github.com/zhuoli99/radiotrax-code-challenge/blob/master/client/src/reducers/devicesSlice.js as an example and attempting to follow it. Any comments or suggestions on their structure is appreciated.

@markerikson

This comment has been minimized.

Copy link
Collaborator

@markerikson markerikson commented Sep 1, 2019

@quantumpotato : please see these articles on working with side effects in Redux:

https://redux.js.org/introduction/learning-resources#side-effects-basics

Per the discussion in this thread, RSK adds the thunk middleware to your store by default in configureStore, but other than that, writing and using thunks is up to you.

The notion of "effects" in this spread specifically is about adding specific syntax for attaching / declaring thunk functions as part of createSlice().

I am currently working on the "Advanced" tutorial for RSK, which will show how to write and use thunks alongside slices. You can see the WIP preview at:

https://deploy-preview-179--redux-starter-kit-docs.netlify.com/tutorials/advanced-tutorial

@quantumpotato

This comment has been minimized.

Copy link

@quantumpotato quantumpotato commented Sep 1, 2019

Cool, looking forward to covering the networking.
I currently am stuck on infinite loops when I call dispatch. The one solution I found was "your mapDispatch h should be an object not a function" and it is.
EDIT: debugging finished, my inner dispatch function was written incorrectly. If you are getting an infinite loop making async, make sure you are sending dispatch in as one variable to the fn returned by your initial call.

@RichiCoder1

This comment has been minimized.

Copy link
Contributor

@RichiCoder1 RichiCoder1 commented Oct 15, 2019

I'm super late to this, but maybe a better idea than action actions might be an async slice?

Super duper rough draft silly idea:

interface AsyncSliceOptions<TData, TRequestPayload> {
    name: string;
    initialState: TData;
    request: (payload: TRequestPayload, dispatch: Dispatch) => Promise<any>;
    mapSuccess?: (payload: unknown, dispatch: Dispatch) => TData;
    mapError?: (payload: unknown, dispatch: Dispatch) => any;
}

export function createAsyncSlice<TData, TRequestPayload>(options: AsyncSliceOptions<TData, TRequestPayload>) {
    const slice = createSlice({
        name: options.name,
        initialState: {
            loading: false,
            data: options.initialState as TData | null,
            error: null as any | null,
        },
        reducers: {
            requestStart(state) {
                state.loading = true;
                state.error = null;
            },
            requestSuccess(state, { payload }) {
                state.error = null;
                state.loading = false;
                state.data = payload;
            },
            requestFailure(state, { payload }) {
                state.error = payload;
                state.loading = false;
                state.data = null;
            }
        }
    });

    const action = (payload: TRequestPayload) => async (dispatch: Dispatch) => {
        try {
            dispatch(slice.actions.requestStart());
            let result = await options.request(payload, dispatch);
            if (options.mapSuccess) {
                result = options.mapSuccess(result, dispatch);
            }
            dispatch(slice.actions.requestSuccess(result));
        } catch (e) {
            let error = e;
            if (options.mapError) {
                error = options.mapError(error, dispatch);
            }
            dispatch(slice.actions.requestFailure(error));
        }
    };

    return [slice, action];
}

It seems a common enough pattern and gives apollo-ish ergonomics. Just an off the wall idea though.

@markerikson markerikson referenced this issue Oct 29, 2019
8 of 12 tasks complete
@Glinkis

This comment has been minimized.

Copy link

@Glinkis Glinkis commented Nov 19, 2019

A minor infliction in this discussion.
It would be awesome if the action names were all the same length, if only to keep the code aesthetically pleasing. The pattern I usually use myself, is something like request, success, failure, or loading, success, failure.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
You can’t perform that action at this time.