Making Hooks framework-agnostic with Redux Tool Kit

by Nick DeJesus

Part 1

Building a Stripe Powered Shopping Cart

Why make things framework-agnostic?

  • Works with everything

use-shopping-cart

 

  • Stripe Powered Shopping Cart Library
  • Handles Shopping Cart State and Logic
  • Only for React developers

Someone asked me if they could use this library with HTML and JS only

I said, "No".

:(

Why did I feel sad?

I originally wanted to be an Android developer

Releasing an app on Android was great, but many iOS users were feeling FOMO

This ultimately lead me to becoming a web developer. 

These same feelings motivated me to make an attempt at making USC framework-agnostic

Where do I start?!

State management libraries

State management libraries are framework agnostic

The goal is to remake the functionality of the hooks but with a state management library of my choice.

Can we talk about Redux?

Redux Then

Redux Now

My take on Redux

Redux is based on a well-known standard called Flux.

However, the library itself is not opinionated at all. This left a lot of room for an ecosystem of different tools and approaches to come into play.

This made redux very hard learn. While there are many "best practices", every redux implementation could be wildly different from the other.

How redux works

"One Way Data Flow"

In comes Redux Tool Kit!

Redux Tool Kit is the official opinionated version of redux. It comes with methods and tools that come from industry best practices.

Everything that made people so frustrated with redux is addressed by RTK

The To-Do List

  • Remake use-shopping-cart state/logic with redux-tool-kit as "core" implementation
  • Write tests for core
  • Create a React wrapper that takes advantage of core implementation
  • Make sure tests for React wrapper work
  • Cry a lil bit
  • Make sure it persists to local storage
  • Redo docs and give examples of it being framework agnostic

(I happened to also change the API a bit during this process)

Redux Tool Kit

Slice

An object full of reducer functions that automatically generates action creators and action types that correspond to the reducers and state.

Slice

USC's Slice

const slice = createSlice({
  name: 'cart',
  initialState,
  reducers: {
    addItem: {
     reducer: (state, { payload: { product, options } }) => { /* LOGIC */ },
     prepare: (product, options = { count: 1 }) => { /* LOGIC */ }
    }
  }
})

Prepare

Prepare gives you access to the params passed to actions before sending them as the actions payload.

(with redux-only you needed a lib for this)

Entry Class

function Entry(
  product,
  quantity,
  currency,
  language,
  price_metadata,
  product_metadata
) {

/* Things happen here */

  return {
    ...product,
    id,
    quantity,
    get value() {
      return this.price * this.quantity
    },
    get formattedValue() {
      return formatCurrencyString({
        value: this.value,
        currency,
        language
      })
    }
  }
}

(A little context)

Store Configuration

import { configureStore } from '@reduxjs/toolkit'
import { reducer, initialState } from './slice'


export function createShoppingCartStore(options) {
  return configureStore({
    reducer,
    preloadedState: { ...initialState, ...options }
  })
}

With redux, you need to create stores for your apps. We had to make a creator function for creating stores with an options param for configuration.

Testing!

Rollup

  • Module Bundler for JavaScript
  • Based on ES2015 Modules
  • Great at removing unused code with tree-shaking
  • Create small, efficient bundles out for your library
  • We used it to build multiple formats (ES, UMD, CJS) as well as the React implementation

Rollup Config

rollup build cmd

Rollup Ouput

Wait . . .

This is no longer a hooks library 🤔

We need to create a wrapper around the core implementation that allows you to interact with it through hooks

React Wrapper

export function CartProvider({ children, ...props }) {
  const store = React.useMemo(() => createShoppingCartStore(props), [props])

  return (
    <Provider context={CartContext} store={store} value={store}>
      {children}
    </Provider>
  )
}

export function useShoppingCart(
  selector = (state) => ({ ...state }),
  equalityFn
) {
  const dispatch = useDispatch()
  const cartState = useSelector(selector, equalityFn)

  const cartActions = bindActionCreators(actions, dispatch)

  const newState = { ...cartState, ...cartActions }

  React.useDebugValue(newState)
  return newState
}

Testing React!

Cry a lil bit

Obstacles

  • redux doesn't like non-serializable data. This caused an issue with the way we initiated Stripe
  • Understanding how to update State with ImmerJS
  • Varying issues with the Rollup and Bundling

Initializing Stripe

Before

import { jsx } from 'theme-ui'
import ReactDOM from 'react-dom'
import { loadStripe } from '@stripe/stripe-js'
import { CartProvider } from 'use-shopping-cart'
import './index.css'
import App from './App'

const stripePromise = loadStripe(process.env.REACT_APP_STRIPE_API_PUBLIC)

ReactDOM.render(
  <CartProvider
    mode="checkout-session"
    stripe={stripePromise}
    billingAddressCollection={false}
    successUrl="https://stripe.com"
    cancelUrl="https://twitter.com/dayhaysoos"
    currency="USD"
  >
    <App />
  </CartProvider>,
  document.getElementById('root')
)

Initializing Stripe

After

/** @jsx jsx */
import { jsx } from 'theme-ui'
import ReactDOM from 'react-dom'
import { CartProvider } from 'use-shopping-cart'
import './index.css'
import App from './App'

ReactDOM.render(
  <CartProvider
    mode="payment"
    cartMode="checkout-session"
    stripe={process.env.REACT_APP_STRIPE_API_PUBLIC}
    billingAddressCollection={false}
    successUrl="https://stripe.com"
    cancelUrl="https://twitter.com/dayhaysoos"
    currency="USD"
  >
    <App />
  </CartProvider>,
  document.getElementById('root')
)

Initialize in Middleware

Middleware allows you to customize the dispatch function. Remember, dispatch is what you use to call your actions.

Middleware use-cases

  • Logging actions
  • Make Async API calls
  •  Stopping/pausing actions
  • Whatever your brain can come up with

We use middleware on USC for:

  • Warnings
  • Persisting state in local storage
  • Initializing Stripe

Custom Middleware

export const handleWarnings = (store) => (next) => async (action) => {
  switch (action.type) {
    // These have `count`
    case 'cart/addItem':
    case 'cart/incrementItem':
    case 'cart/decrementItem':
      if (!isValidCount(action.payload?.options?.count, action)) return
      break
    // This one has `quantity`
    case 'cart/setItemQuantity':
      if (!isValidCount(action.payload?.quantity, action)) return
      break
    default:
      break
  }

  return next(action)
}

Warning Middleware

Custom Middleware

Stripe Middleware

export const handleStripe = (store) => (next) => async (action) => {
  const stripePublicKey = store.getState().stripe
  const cart = store.getState()
  let stripe

  if (action.type === 'cart/redirectToCheckout') {
    try {
      stripe = await loadStripe(stripePublicKey)
    } catch (error) {
      console.log('error', error)
    }
    if (cart.cartMode === 'checkout-session') {
      return stripe.redirectToCheckout({
        sessionId: action.payload.sessionId
      })
    }

    if (cart.cartMode === 'client-only') {
      const checkoutData = getCheckoutData(cart)
      stripe.redirectToCheckout(checkoutData)
    }

    throw new Error(`Invalid value for "cartMode" was found: ${cart.cartMode}`)
  }
  
  }

Immer JS

When updating redux, you're not allowed to directly mutate state, you have to return a new state. 

Immer allows you to "mutate state" by letting you update a draft version of the state. It handles the returning of the next state for you.

Rollup Considerations

  • Need to properly manage external dependencies
  • Need to consider different ways of importing/exporting
  • Need to make sure package bundle stays small

Testing with HTML/JS

    <script src="https://unpkg.com/redux@latest/dist/redux.min.js"></script>
    <script
      crossorigin
      src="https://unpkg.com/use-shopping-cart@3.0.0-beta.3/dist/core.umd.js"
    ></script>
let store = UseShoppingCartCore.createShoppingCartStore({
        stripe: testApiKey,
        mode: "client-only",
        successUrl: "https://twitter.com/dayhaysoos",
        cancelUrl: "https://stripe.com",
      });

redux-persist

redux-persist is a middleware that makes sure your data is in sync with local storage. 

Also compatible with React Native!

redux-persist set up

export function createShoppingCartStore(options) {
  const persistConfig = {
    key: 'root',
    version: 1,
    storage,
    whitelist: ['cartCount', 'totalPrice', 'formattedTotalPrice', 'cartDetails']
  }
  const persistedReducer = persistReducer(persistConfig, reducer)

  const newInitialState = { ...initialState, ...options }
  updateFormattedTotalPrice(newInitialState)

  return configureStore({
    reducer: persistedReducer,
    preloadedState: newInitialState,
    middleware: (getDefaultMiddleware) =>
      getDefaultMiddleware({
        serializableCheck: {
          ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER]
        }
      }).concat(handleStripe, handleWarnings)
  })
}

redux-persist react

export function CartProvider({ loading = null, children, ...props }) {
  const store = React.useMemo(() => createShoppingCartStore(props), [props])
  const persistor = persistStore(store)

  return (
    <Provider context={CartContext} store={store}>
      <PersistGate
        persistor={persistor}
        children={(bootstrapped) => {
          if (!bootstrapped) return loading
          return children
        }}
      />
    </Provider>
  )
}

Still work to do!

  • Update docs site
  • Need more examples
  • Would love to see a Vue wrapper for this
  • Need to wrap up tests and test examples as well
  • Need to document migrating from old version of lib to this one

Thank you!!!

Making Hooks framework-agnostic with Redux Tool Kit

By Nick De Jesus

Making Hooks framework-agnostic with Redux Tool Kit

  • 347