piotrwitek/typesafe-actions

v5.0.0 - Rewrite and simplify `createAction` API

piotrwitek opened this issue ยท 31 comments

Issuehunt badges

Is your feature request related to a real problem or use-case?

Currently, createAction API is not optimal and require some unnecessary boilerplate code (historically it was created pre TS v3.0 so there was a lot of limitations having an impact on API shape):

const action = createAction('TYPE', resolve => {
    return (name: string, id: string) => resolve(id);
  });

Describe a solution including usage in code example

Today with recent TypeScript features I can finally rewrite the API to something simpler, more intuitive and more consistent across the board.

This will resolve few existing feature requests:

createAction

type User = { id: number, name: string };
const user: User = { id: 1, name: 'Piotr' };

const action1 = createAction('TYPE1')<string>();
action1(user.name); // => { type: 'TYPE1', payload: 'Piotr' }

const action2 = createAction(
  'TYPE1',
  (user: User) => user.name, // payload creator
)<string>();
action2(user); // => { type: 'TYPE1', payload: 'Piotr' }

const actionWithMeta1 = createAction('TYPE2')<string, number>();
actionWithMeta1(user.name, user.id); // => { type: 'TYPE2', payload: 'Piotr', meta: 1 }

const actionWithMeta2 = createAction(
  'TYPE2',
  (user: User) => user.name, // payload creator
  (user: User) => user.id, // optional meta creator
)<string, number>();
actionWithMeta2(user); // => { type: 'TYPE2', payload: 'Piotr', meta: 1 }

createAsyncAction

const action = createAsyncAction(
  'REQ_TYPE', 'SUCCESS_TYPE', 'FAILURE_TYPE', 'CANCEL_TYPE',
)<Request, Response, Error, undefined>();

const action = createAsyncAction(
  ['REQ_TYPE', (req: Request) => req], // request payload creator
  ['SUCCESS_TYPE', (res: Response) => res], // success payload creator
  ['FAILURE_TYPE', (err: Error) => err], // failure payload creator
  'CANCEL_TYPE', // optional cancel payload creator
)();

const actionWithMeta = createAsyncAction(
  'REQ_TYPE', 'SUCCESS_TYPE', 'FAILURE_TYPE', 'CANCEL_TYPE',
)<[Request, Meta], Response, [Error, Meta], undefined>();

const actionWithMeta = createAsyncAction(
  ['REQ_TYPE', (req: Request) => req, (req: Request) => 'meta'], // request payload & meta creators
  ['SUCCESS_TYPE', (res: Response) => res], // success payload creator
  ['FAILURE_TYPE', (err: Error) => err, (err: Error) => 'meta'], // failure payload & meta creators
  'CANCEL_TYPE', // optional cancel payload creator
)();

createCustomAction

All remaining non-standard use-cases you will be able to cover with createCustomAction

const action1 = createCustomAction('TYPE1', (name: string) => ({ payload: name }));

const action2 = createCustomAction('TYPE2', (name: string, id: string) => ({
  payload: name, meta: id,
}));

Who does this impact? Who is this for?

All TypeScript users

Additional context (optional)

createAction and createStandardAction will be deprecated because new createAction will be able to replace their function.

In v5, the old API will be kept in deprecated import so incremental migration is possible:

import { deprecated } from "typesafe-actions";

const { createAction, createStandardAction } = deprecated;

IssueHunt Summary

Backers (Total: $200.00)

Become a backer now!

Or submit a pull request to get the deposits!

Tips


IssueHunt has been backed by the following sponsors. Become a sponsor

@IssueHunt has funded $150.00 to this issue.


This is not related to #116, right?

createAction and createCustomAction will be deprecated.

@sjsyrek It means it would become unnecessary

It's still just a proposal, It's not finalized yet, open to discussion.

I updated the proposal with improved createAsyncAction API that will resolve 2 existing feature requests:

This looks great!

Edit - is there a way to set the error: boolean parameter of a FSA in this approach? These purpose-built payload and meta creation functions are more specific than the general "mapper" approach from before -- maybe I'm missing something

Hey @davidgovea, please help me to figure out this feature.

I need to understand your use-case and how you would like to use this feature from a user perspective.
Could you send me a real usage example of how you're using the error flag with a current v4 API?
Please include actions, reducer and any other code that is invoking actions and setting error flag (I need to see what parameters you're passing to action creator with their types).

Thanks

Would something like this work with the new API?

const action = createAsyncAction(
  'REQ_TYPE',
  ['SUCCESS_TYPE', res => {
    // do something with res
  }],
  'FAILURE_TYPE'
)<[Request, Meta], Response, [Error, Meta]>;

If not I guess I could simply pass identity function with the type in 1st and 3rd param, but something like this would also be pretty useful.

Hey @paolostyle, that's an interesting idea, I think that might be possible. I haven't thought of it and I really appreciate your feedback.

I'll be working on the implementation this weekend so I'll give it a shot.

If that would work I think I would drop the second variant and use only the version from your proposal as it's basically solving both use-cases with one function which is a superior API.

Challenge 1:

One limitation is it'll only allow mapper functions with a single argument. I think it might still be ok because the previous version of async action has the same limitation and practically no one was complaining about it, so I guess most users can live without it.

Possible solution:

const action = createAsyncAction(
  'REQ_TYPE',
  ['SUCCESS_TYPE', (res, meta) => {
    // do something with res
  }],
  'FAILURE_TYPE'
)<[Request, Meta], (res: Response, meta: Meta) => Results, [Error, Meta]>;

will v5 release fix createAction to handle optional payload/meta? I think currently it doesn't.

// this keeps the correct type
export const decrement1 = (payload?: number) => action(DECREMENT, payload)

// while this doesn't
export const decrement2 = createAction(DECREMENT, action => {
  return (payload?: number) => action(payload);
});

@bboydflo yes you are correct, createAction is currently not supporting optional payload type correctly, it'll be fixed in new API.

Hi @piotrwitek - These changes look great. I wanted to quickly check-in and see how progress was coming.

Thank you for all the hard work :)

Just returned from a Summer Break, will come back to this real soon!

Not sure if this is isolated to 5.0.0-3 or not (if you suspect it's not just let me know and I'll make a new issue), but createReducer allows you to add extra items than what's on the defined state. It properly type-checks keys defined in the state type for the reducer...it just allows extra keys (not the end of the world, unless someone makes a spelling mistake).

Example (the extra property has no typescript errors reported..doing anything invalid to the other two properties properly shows errors):

export type AppState = {
  previous: AppStateStatus | null;
  current: AppStateStatus;
};

export type AppStateReducerState = DeepReadonly<{
  appState: AppState;
}>;

export const defaultAppReducerState: AppStateReducerState = {
  appState: {
    previous: null,
    current: 'active',
  },
};

export const appStateReducer = {
  appState: createReducer(defaultAppReducerState.appState).handleAction(
    setAppState,
    (state, { payload }) => ({
      previous: state.current,
      current: payload,
      extra: 'why is this allowed?',
    }),
  ),
};

@jstheoriginal It's a good find, thanks for reporting.
It would be great to have it as a separate issue to make sure it's a known problem.

@jstheoriginal Not specific to 5.0. I remember this coming up when I attempted to use createReducer once before. Did you open an issue? If not, I can.

Hey, thanks for the great library! It makes working with using react-redux with TS a lot better. I was wondering if you'd seen the TypeScript action creator code in NgRx, the Angular redux library? It does a good job of avoiding boilerplate, while retaining type safety for things like reducers. Might be a source of inspiration.

@jonrimmer has funded $50.00 to this issue.


Hey @jonrimmer,
Thanks for adding to the funding, that's really appreciated!
Regarding your suggestion, I haven't seen it, could you link to some more advanced usage examples/scenarios that are including payloads, meta, async actions so I could compare with new API proposal and check for possible improvements? Thanks!

No problem! The actual creators are fairly simple, they work like your examples above. The main thing I noticed is that they've managed to get type inference on the reducer functions without having to register root actions or extend the module, e.g.

const scoreboardReducer = createReducer(
  initialState,
  on(loadTodos, state => ({ ...state, todos })),
  on(ScoreboardPageActions.awayScore, state => ({ ...state, away: state.away + 1 })),
  on(ScoreboardPageActions.resetScore, state => ({ home: 0, away: 0 })),
  on(ScoreboardPageActions.setScores, (state, { game }) => ({ home: game.home, away: game.away }))
);

So, similar to createReducer() and handleAction() in the current version of typesafe-actions, just slightly smoother. Not sure how they do it though!

@jonrimmer actually that automatic inference is easy to add, what differentiate typesafe-actions from all other libraries is the fact that createReducer knows about all valid actions in the system (that's why you register RooAction) and informs you in real-time which actions are already handled in this reducer and which not allowing for quicker understanding of the bigger picture without a need to browse through the files containing declarations of actions. I can guess this is most certainly not the case in the above example because it is impossible without extra type parameter providing the constraint type.

  • Feature is completed and will be published today as beta v5.1.0-0.
  • Migration guide is ready #191
  • Documentation will be updated later, so for now please use examples found in the first comment #189

For anyone coming from search engines with the following bug:

argument 1 is invalid it should be an action-creator instance from typesafe-actions

My mistake was that I didn't see the docs were not updated yet. I solved it by using the new createAction() API given by @piotrwitek in the first post of this thread.

Example:

export const updateProviders = createAction(
  "UPDATE_PROVIDERS",
  (provider: Provider) => provider,
)<Provider>();

Go and checkout the other breaking changes https://github.com/piotrwitek/typesafe-actions/releases which are not in the official docs yet, too.

Maybe this should be a new issue, but I'm not sure I understand the benefit/point of the extra function call in createAction. Given the above example:

export const updateProviders = createAction(
  "UPDATE_PROVIDERS",
  (provider: Provider) => provider,
)<Provider>();

The generic Provider is... provided... twice. Does this actually help the typing? I'm curious why this is necessary.

@mAAdhaTTah good question.
Looking at the above example you don't want to use extra function call in that case, because it's not designed for it. There is a simpler way to do that:

export const updateProviders = createAction(
  "UPDATE_PROVIDERS",
)<Provider>();

But it can be useful in other more complex cases, especially when you want to set the constraints for the types you expect.

Consider this:

const deleteUser = createAction(
  'TYPE1',
  (user: User) => user.id,
)<string>();

deleteUser(userObject) // { type: 'TYPE1', payload: string }

This will create an action creator that will accept argument user of type User, but the payload will be string.
Hope that is clear!

Is there a possibility to add meta data to all the actions?
With this snippet:

const actionWithMeta = createAsyncAction(
  'REQ_TYPE', 'SUCCESS_TYPE', 'FAILURE_TYPE', 'CANCEL_TYPE',
)<[Request, Meta], Response, [Error, Meta], undefined>();

I can call actionWithMeta.success(request, myMeta); and the meta is applied, but it gives a typing error, expecting only 1 argument.

I'd need to have a certain metadata field on ALL the actions for my custom redux middleware.

@DalderupMaurice yes you can just edit the types arguments:

const actionWithMeta = createAsyncAction(
  'REQ_TYPE', 'SUCCESS_TYPE', 'FAILURE_TYPE', 'CANCEL_TYPE',
)<[Request, Meta], [Response, Meta], [Error, Meta], [undefined, Meta]>();

Hello! I'm trying to configure a basic action/reduce setup. I've got the following action:

export const editorMetaAuthorChange = createAction('editor/META_AUTHOR_CHANGE')<string>()

Then this should be the relative reducer:

export interface EditorState {
  meta: {
    author: string
    name: string
    description: string
    tags: string[]
  }
}

const initialState: EditorState = {
  meta: {
    author: '',
    name: '',
    description: '',
    tags: [],
  },
}

const reducer = createReducer(initialState)
  .handleAction(editorMetaAuthorChange, (state, { payload }) => ({
    ...state,
    meta: {
      ...state.meta,
      author: payload,
    },
  }))

The problem is that in handleAction if I don't specify the state's and the action's types, they will be any. What am I doing wrong? Shouldn't typesafe-actions take care of this?

Thanks :)

Hello! I'm trying to configure a basic action/reduce setup. I've got the following action:

export const editorMetaAuthorChange = createAction('editor/META_AUTHOR_CHANGE')<string>()

Then this should be the relative reducer:

export interface EditorState {
  meta: {
    author: string
    name: string
    description: string
    tags: string[]
  }
}

const initialState: EditorState = {
  meta: {
    author: '',
    name: '',
    description: '',
    tags: [],
  },
}

const reducer = createReducer(initialState)
  .handleAction(editorMetaAuthorChange, (state, { payload }) => ({
    ...state,
    meta: {
      ...state.meta,
      author: payload,
    },
  }))

The problem is that in handleAction if I don't specify the state's and the action's types, they will be any. What am I doing wrong? Shouldn't typesafe-actions take care of this?

Thanks :)

Im having same issue

// actions
export const updatePreferenceRequest = createAction(
  '@preference/UPDATE_PREFERENCE_REQUEST'
)<UpdatePreferenceData>();

// REDUCER
const INITIAL_STATE: StatePreference = {
  totalCountMax: 100,
  vibration: true,
  sound: true,
   loading: false,
};

const preferenceReducer = createReducer(INITIAL_STATE)
.handleAction(
  updatePreferenceRequest,
  (state: StatePreference, { payload }) => ({
    ...state,
    loading: true,
  });

state and payload
Parameter 'state' implicitly has an 'any' type

@allandiego I've kind of "resolved" writing a supporting library: #229

@elegos i ended up switching to the official redux lib:
https://redux-toolkit.js.org

@piotrwitek

Are generics not supported for createCustomAction createHandler?

enum Element {
  Elem1 = 'elem1',
  Elem2 = 'elem2',
}

type ElementValueMapper = {
  [Element.Elem1]: string;
  [Element.Elem2]: number;
};

type Explicit = ElementValueMapper[Element.Elem1]; // correct type string

const action = createCustomAction(
  'someAction',
  <T extends Element>(e: T, value: ElementValueMapper[T]) => ({
    payload: { e, value },
  })
);

action(Element.Elem1, 5); // type of value is string | number, should be only string

It is possible when I create action by hand:

const action = <T extends Element>(
  name: T,
  value: ElementValueMapper[T]
): PayloadAction<'changevalue', { name: T; value: ElementValueMapper[T] }> =>
  ({
    type: 'changevalue',
    payload: { name, value },
  } as const);

action(Element.Elem1, 5); // Argument of type '5' is not assignable to parameter of type 'string'.