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

refactor(redux): Reorganize actions and reducers #212

Merged
merged 3 commits into from
Sep 14, 2018
Merged

Conversation

jimbo
Copy link
Contributor

@jimbo jimbo commented Aug 6, 2018

This PR is a:

[ ] New feature
[x] Enhancement/Optimization
[x] Refactor
[ ] Bugfix
[ ] Test for existing code
[ ] Documentation

Summary

When this pull request is merged, we'll once again use redux-actions to create reducers and actions. This will prepare us for incoming changes related to extensibility. It will also make them far more consistent.

Additional information

Checkout components have been refactored to accommodate new app state structure. They should now be ready for editable forms to be added (and real cart modification requests to be made).

@jimbo jimbo requested a review from zetlen August 6, 2018 19:08
const actionTypes = ['TOGGLE_DRAWER'];

const actions = createActions(...actionTypes, { prefix });
export default actions;
Copy link
Contributor Author

@jimbo jimbo Aug 6, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a standard way to create both action types (string constants that reducers look for) and their corresponding boilerplate action creators. Almost all sync action creators have a sort of identity signature:

  • It returns an action object
  • It defines a a payload prop on that object, whose value is the first argument
  • If payload is an Error, it defines an error prop, whose value is true

The docs suggest that exporting action types and action creators can reduce boilerplate and keep action logic centralized. Here, the action creators generated by createActions are named such that they can act as action types.

actions.toggleDrawer.toString(); // => 'APP/TOGGLE_DRAWER'
actions.toggleDrawer(null); // => { type: 'APP/TOGGLE_DRAWER, payload: null }

} catch (error) {
console.log(error);
}
};
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Async action creators aren't actually action creators at all. They don't return an action object with a type, and they aren't handled by reducers. They're just higher-order functions useful for orchestrating dispatches.

Here, loadReducers doesn't even dispatch any actions. Should we still call it an async action creator? or a helper? Is there a difference, and does it matter?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think loading reducers should dispatch an action! APP/REDUCERS_ADDED seems like an opportunity for a UI to re-render itself if it knows it's subject to a lot of new additions.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a reducer is added, the store actually replaces its root reducer, which instructs connected components to re-render (like an action does). And there's nothing that a reducer should do in response to REDUCERS_ADDED; even updating the list of current slices is pointless, since getState() would be the source of truth for that info.

I think we can improve the reducer-adding workflow—turn it into a pipeline, maybe—as we open up to extensibility. But for now I think this is actually fine.


const { request } = RestApi.Magento2;
const storage = new BrowserPersistence();
Copy link
Contributor Author

@jimbo jimbo Aug 6, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since actions are similar to events, a reducer can seem like a good place to "listen" for "events" and perform side effects such as local persistence. However, one of the few firm rules in Redux says that reducers should perform no side effects.

Things you should never do inside a reducer:

  • Mutate its arguments;
  • Perform side effects like API calls and routing transitions;
  • Call non-pure functions, e.g. Date.now() or Math.random().

So I had to migrate the storage logic from the cart reducer here to the cart actions. It's largely unchanged. However, an alternate way to handle this logic on the "listening" side would be to put it in a middleware.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BrowserPersistence is a hack in the first place; we need localStorage just so guest carts can survive hard refreshes. Thanks for moving it (assuming it still works and you get the same cart on a hard refresh; do you?), but it's still here temporarily. Logic like this should be in a ServiceWorker, or in a middleware that helps us simulate one.

@coveralls
Copy link

coveralls commented Aug 6, 2018

Pull Request Test Coverage Report for Build 872

  • 0 of 244 (0.0%) changed or added relevant lines in 19 files are covered.
  • 12 unchanged lines in 8 files lost coverage.
  • Overall coverage decreased (-0.6%) to 29.782%

Changes Missing Coverage Covered Lines Changed/Added Lines %
packages/venia-concept/src/shared/durations.js 0 1 0.0%
packages/venia-concept/src/components/Checkout/receipt.js 0 1 0.0%
packages/venia-concept/src/components/Header/navTrigger.js 0 1 0.0%
packages/venia-concept/src/components/MiniCart/trigger.js 0 2 0.0%
packages/venia-concept/src/components/Page/page.js 0 3 0.0%
packages/venia-concept/src/components/Navigation/trigger.js 0 3 0.0%
packages/venia-concept/src/components/Checkout/submitButton.js 0 4 0.0%
packages/venia-concept/src/components/Checkout/checkoutButton.js 0 5 0.0%
packages/venia-concept/src/reducers/app.js 0 5 0.0%
packages/venia-concept/src/components/Checkout/cart.js 0 7 0.0%
Files with Coverage Reduction New Missed Lines %
packages/venia-concept/src/actions/checkout.js 1 0.0%
packages/venia-concept/src/components/Navigation/trigger.js 1 0.0%
packages/venia-concept/src/components/MiniCart/miniCart.js 1 0.0%
packages/venia-concept/src/components/Checkout/flow.js 1 0.0%
packages/venia-concept/src/reducers/checkout.js 1 0.0%
packages/venia-concept/src/components/MiniCart/trigger.js 1 0.0%
packages/venia-concept/src/reducers/app.js 1 0.0%
packages/venia-concept/src/actions/cart.js 5 0.0%
Totals Coverage Status
Change from base Build 870: -0.6%
Covered Lines: 519
Relevant Lines: 1769

💛 - Coveralls

await dispatch(createGuestCart());
}

// retrieve app state again
// retrieve the new guest cart from state
return getState().cart.guestCartId;
}
Copy link
Contributor Author

@jimbo jimbo Aug 7, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here, we have another example of the ambiguity around async action creators.

  • An async action creator is a function that receives a payload and returns a thunk
  • A thunk is a function that receives dispatch and getState, performs side effects, and returns undefined

Here, we have a function that's essentially a naked thunk—it's not the product of a higher-order function—and it both performs side effects (using dispatch) and returns a value (using getState). Currently, it's exclusively used by async action creators, since it's of little use to components due to its shape.

What should we do with this function? It feels like an anti-pattern.

};

const enterSubflow = () =>
const actions = createActions(actionMap, ...actionTypes, { prefix });
export default actions;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createActions, provided by redux-actions, has a wonderful, optional API that allows us to nest action creators. While it's a bit overkill for most action types, I think it's perfect for action types associated with API requests, which often fit a request-success-error pattern.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, let's go with prior art here. There should be simple recipes for the sequence of action dispatch API calls and other common interactions (say, additional logic loaded via a chunk?), like:

import { lifecycle } from '@magento/peregrine';

const cartLifecycle = lifecycle('CART');

const cartApiLifecycle = cartLifecycle.requests({
    GET_DETAILS(state) {
       return {
         path: `/guest-carts/${state.guestCartId}`
      }
    },
    ADD_ITEM(state, item) {
       return {
          path: `/guest-carts/${state.guestCartId}/items/`,
          method: 'POST',
          body: item
      }
  },
  async (path, method, body) => M2ApiRequest.request(path, { method, body })
);

const [getDetails, addItem] = cartApiLifecycle.getActions();

const reducer = cartApiLifecycle.getReducer();

We don't need to make those abstractions yet, of course.

editing: null,
step: 'cart',
submitting: false,
valid: false
};
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Storing validity here as a simple boolean, valid, is definitely an oversimplification. This will get much more complex as we start to track client- and server-side validation of various parts of the form and the workflow.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think that reducers should be autogenerated at the same time as actions, see comment above.

Copy link
Contributor

@zetlen zetlen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this is a tall order, but I'm submitting it anyway: This seems like a golden opportunity to add some unit testing in Venia. Think you can do it>

} catch (error) {
console.log(error);
}
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think loading reducers should dispatch an action! APP/REDUCERS_ADDED seems like an opportunity for a UI to re-render itself if it knows it's subject to a lot of new additions.


const { request } = RestApi.Magento2;
const storage = new BrowserPersistence();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BrowserPersistence is a hack in the first place; we need localStorage just so guest carts can survive hard refreshes. Thanks for moving it (assuming it still works and you get the same cart on a hard refresh; do you?), but it's still here temporarily. Logic like this should be in a ServiceWorker, or in a middleware that helps us simulate one.

};

const enterSubflow = () =>
const actions = createActions(actionMap, ...actionTypes, { prefix });
export default actions;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, let's go with prior art here. There should be simple recipes for the sequence of action dispatch API calls and other common interactions (say, additional logic loaded via a chunk?), like:

import { lifecycle } from '@magento/peregrine';

const cartLifecycle = lifecycle('CART');

const cartApiLifecycle = cartLifecycle.requests({
    GET_DETAILS(state) {
       return {
         path: `/guest-carts/${state.guestCartId}`
      }
    },
    ADD_ITEM(state, item) {
       return {
          path: `/guest-carts/${state.guestCartId}/items/`,
          method: 'POST',
          body: item
      }
  },
  async (path, method, body) => M2ApiRequest.request(path, { method, body })
);

const [getDetails, addItem] = cartApiLifecycle.getActions();

const reducer = cartApiLifecycle.getReducer();

We don't need to make those abstractions yet, of course.

case 'STEP_3': {
child = <Exit resetCheckout={resetCheckout} />;
break;
case 'receipt': {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a mismatch. Why's it here in this otherwise very consistent flow, which sets child and identifies itself as a step number?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. 👍

@@ -23,17 +23,4 @@ ReactDOM.render(
document.getElementById('root')
);

if (process.env.SERVICE_WORKER && 'serviceWorker' in navigator) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change seems unrelated.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. This shouldn't have been included in the PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. 👍

editing: null,
step: 'cart',
submitting: false,
valid: false
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think that reducers should be autogenerated at the same time as actions, see comment above.

@jimbo jimbo force-pushed the jimbo/redux-reorg branch 4 times, most recently from 5df5a8b to af9bb01 Compare August 13, 2018 22:25
@jimbo jimbo changed the base branch from master to develop September 14, 2018 20:06
@jimbo jimbo merged commit dc5070f into develop Sep 14, 2018
jimbo added a commit that referenced this pull request Sep 25, 2018
- refactor(redux): Reorganize actions and reducers (#212)
- feature(checkout): Add address form to checkout flow (#226)
- test(redux): Add tests for redux actions (#239)
zetlen pushed a commit that referenced this pull request Sep 25, 2018
* Reorganize redux, add address form

- refactor(redux): Reorganize actions and reducers (#212)
- feature(checkout): Add address form to checkout flow (#226)
- test(redux): Add tests for redux actions (#239)

* fixup
@supernova-at supernova-at deleted the jimbo/redux-reorg branch July 25, 2019 15:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants