State Management
We use Redux for global state management with the official React binding.
We use Redux Toolkit for reducing boilerplate and to make Redux fun again.
The examples below are based on the src/modules/api/portfolio
module to stay consistent with the examples in
Testing with Jest
There is a video on Google Drive that walks through creating a new state module.
Constants
Example
const { REACT_APP_API_URL = '' } = process.env
export const NAMESPACE = 'portfolio'
const BASE = `${REACT_APP_API_URL}/portfolios`
const ENTITY = `${BASE}/:id`
export const ENDPOINTS = {
BASE,
SUMMARIES: `${BASE}/summaries`,
}
Events (AKA Redux Actions)
Events are simply redux actions, named "events" for parity with the CQRS
pattern in our backend services. Also "event" describes the concept better than "action" as they only describe what has
happened. Events are payloads of information that send data from the app to the store. They are the only source of
information for the store. You send them to the store using store.dispatch()
.
Example
The reducer object keys are the events. The function value is the reducer.
const portfolioApiSlice = createSlice({
name: `${NAMESPACE}Api`,
initialState,
reducers: {
portfolios_request: (state, { payload }) => {},
portfolios_success: (state, { payload }) => {
// ...reducer
},
Epics
An Epic is the core primitive of redux-observable. It is a function which takes a stream of events and returns a stream of events. Events in, events out. Compose and cancel async events to create side effects and more.
Example
export const apiFetchEntity = (action$: $TSFixMe, state$: $TSFixMe, { get, catchRestError }: $TSFixMe) =>
action$.pipe(
ofType(events.portfolio_request),
mergeMap((action: AnyAction) => {
return get(ENDPOINTS.FETCH_PORTFOLIO, state$, { id: action.payload }).pipe(
pluck('response'),
map(res => {
return events.portfolio_success(transformers.transformGetPortfolioResponse(res))
}),
catchRestError(action),
)
}),
tag('portfolio/epics/apiFetchEntity'),
)
Reducers
Reducers specify how the application's state changes in response to events sent to the store. Remember that events only describe what happened, but don't describe how the application's state changes.
Example
const initialState = {
allIds: [], // Used for ordering
byId: {},
}
const portfolioApiSlice = createSlice({
name: `${NAMESPACE}Api`,
initialState,
reducers: {
portfolios_request: (state, { payload }: $TSFixMe) => {},
portfolios_success: (state, { payload }) => {
/** @todo refactor when API is updated with flatten portfolios */
const portfolios: $TSFixMe[] = pipe(map(prop('portfolios')), flatten)(payload)
state.allIds = map(prop('id'), portfolios)
forEach((p: any) => {
state.byId[p.id] = p
}, portfolios)
},
},
})
Selectors
A selector is a function that derives data from the Redux store. Reselect is a simple library for creating memoized, composable selectors.
Example
export const getSlice = (key: string) => createSelector([`api.${NAMESPACE}.${key}`], identity)
/**
* Get a list of portfolio IDs
*
* @returns {Array} List of portfolio Ids
*/
export const getPortfolioIds = getSlice('allIds')
/**
* Get a list of portfolio objects
* Correctly orders portfolios based on API response using `allIds` index
*
* @returns {Array} List of portfolio objects
*/
export const getPortfolios = createSelector([identity], state =>
map((id: any) => path([id], getSlice('byId')(state)), getPortfolioIds(state)),
)
Transformers
A transformer is a function that transforms and sanitizes data between API requests and responses. An example of one type of transformation is currency. The backend works with currency in decimals, while the client works with currency in cents. Transformers are used to convert decimals to cents on responses and vice-versa for requests.
Transformsers are created using the Morphism Library
Example
The below example is collabsed with ...
to keep things shorter.
const getPortfolioResponseSchema = {
id: 'id',
createdByUserId: 'createdByUserId',
...
/** @todo handle unmanaged */
contractContainer: ({ contractContainer }: any) => {
const type = get(contractContainer, 'type', '')
const terms = get(contractContainer, 'value.terms', {})
return (
contractContainer && {
type,
value: {
terms: {
...
damageDepositAmount: toCents(get(terms, 'damageDepositAmount', 0)).getAmount(),
monthlyRentAmount: toCents(get(terms, 'monthlyRentAmount', 0)).getAmount(),
applicationFee: {
netAmount: toCents(get(terms, 'applicationFee.netAmount', 0)).getAmount(),
vatable: get(terms, 'applicationFee.vatable', false),
},
},
},
}
)
},
parties: 'parties',
invoiceTemplates: ({ invoiceTemplates }: any): $TSFixMe => {
return invoiceTemplates?.map((template: any) => ({
...template,
netAmount: toCents(get(template, 'netAmount', 0)).getAmount(),
...
}))
},
agents: 'agents',
leaseIdentifier: 'leaseIdentifier',
leaseTerms: 'leaseTerms',
settings: 'settings',
segments: ({ segments = [] }) => uniq(segments),
commission: ({ commission }: any) =>
commission && {
managementFee: transformCommissionFee(get(commission, 'managementFee')),
procurementFee: transformCommissionFee(get(commission, 'procurementFee')),
},
renewal: ({ renewal }: any) =>
renewal && {
newRentAmount: toCents(renewal.newRentAmount).getAmount(),
newCommission: {
managementFee: transformCommissionFee(get(renewal, 'newCommission.managementFee')),
procurementFee: transformCommissionFee(get(renewal, 'newCommission.procurementFee')),
},
depositTopUp: toCents(renewal.depositTopUp).getAmount(),
renewalFee: toCents(renewal.renewalFee).getAmount(),
leaseRenewsAt: renewal.leaseRenewsAt,
newLeaseTerms: renewal.newLeaseTerms,
},
terminatedReason: 'terminatedReason',
status: ({ status = 'draft' }) => toLower(status),
}
export const transformGetPortfolioResponse = (source: any) => morphism(getPortfolioResponseSchema, source)
Utils
For common operations, data manipulation etc.
Example
This example using Ramda.
export const getTenantAndLandlordIdsFromPortfolio = (portfolio: any) =>
pipe(
prop('parties'),
props(['tenants', 'owners']),
map(props(['primary', 'secondary'])),
flatten,
map(prop('partyId')),
filter((id: string) => !!id),
)(portfolio)
The same code in pure JS could be written like this (untested).
export const getTenantAndLandlordIdsFromPortfolio = (portfolio: any) => {
const { parties } = portfolio
const { tenants, owners } = parties
return [
tenants.primary.partyId,
...tenants.secondary.map(tenant => tenant.partyId),
owners.primary.partyId,
...owners.secondary.map(owner => owner.partyId),
].filter((id: string) => !!id)
}
Hooks
Custom hooks are for sharing reusable functionality between components. Refer to Building Custom Hooks for a visual guide what they are and how to create custom hooks.
Example
/**
* Ensures the portfolio and property is fetched and returns useful property fields
*
* @param {string} portfolioId Portfolio UUID
*/
export const usePortfolioProperty = (portfolioId: any): $TSFixMe => {
const dispatch = useDispatch()
const portfolio = useSelector(state => portfolioApiSelectors.getPortfolioById(state)(portfolioId))
const propertyId = path(['propertyId'], portfolio)
const propertyStreetAddress = useSelector(state => propertyApiSelectors.getPropertyStreetAddress(state)(propertyId))
useEffectOnce(() => {
if (!portfolio && portfolioId) {
dispatch(portfolioApiEvents.portfolio_request(portfolioId))
}
})
useEffect(() => {
if (!propertyStreetAddress && propertyId) {
dispatch(propertyApiEvents.property_request({ id: propertyId }))
}
}, [propertyStreetAddress, dispatch, propertyId])
return {
propertyId,
propertyStreetAddress: portfolio && propertyStreetAddress,
}
}
Index (module contract)
The index file serves as the modules public contract. Other modules interface with this module by importing from the contract. Other modules should not import directly from other files within the module. This allows the module to refactor easily without having an impact on the rest of the application.
Example
import * as portfolioApiConstants from './constants'
import { events as portfolioApiEvents, reducer as portfolioApiReducer } from './state'
import * as portfolioApiEpics from './epics'
import * as portfolioApiSelectors from './selectors'
import * as portfolioApiTransformers from './transformers'
import * as portfolioApiUtils from './utils'
import * as portfolioApiHooks from './hooks'
export {
portfolioApiConstants,
portfolioApiEpics,
portfolioApiEvents,
portfolioApiReducer,
portfolioApiSelectors,
portfolioApiTransformers,
portfolioApiUtils,
portfolioApiHooks,
}