Working on a living product after 2 years on design systems and component libraries has inspired reflections on technical choices of startups past.
In this post, I recount some learnings from using or refusing common, "boring", flux-informed stores for managing application state.
🍿 The following is a tale of 3 front end architectures spanning 2018-2024, each with a different stack and state management solution in a regulated domain. 1
I. The fintech app with several thousand trial users
The Stack: React 14, React-Redux, Yup, Bootstrap, Express, MongoDB, Redis
My first encounter with sophisticated store usage was React-Redux at a B2B fintech company in 2019. We were using React 11 for dual-module application, its state all sewn together by several connect()
functions.2
Redux solved the need for a view-level source of truth to organize feature-specific state, especially when different parts of the app needed to access information specific to a particular feature.
Below is a simplistic view of feature states to enable the user to add stocks to portfolios, and contact customer service.
Each feature has its own reducer, and the store combines them all.
// store.js
const configureStore = () => {
const store = createStore(
rootReducer: {
account: accountReducer,
stock: stockReducer,
support: supportReducer,
}
...
);
return store;
};
The plans reflect these business requirements:
- the ability to find and add stocks to a portfolio
- to view and compare the progress of purchased stocks
- the ability to privately discuss portfolios with customer support agents
Just as you wouldn't make an API call to the server every time you need to display data, a store acts as an intermediary space for storing and manipulating presentational data. POST
or DELETE
requests to your server could then be made as needed and responses would also update the store.
With each app feature having its own store, operations around specific modules are easier to maintain ...as long as we plan carefully.
II. Modernizing a surgery prioritization system
The Stack: Angular 8, Dynamic forms, RxJS, and Angular Material, FHIR Server
In 2020, I landed at a health tech company whose forte was clinical data storage, but they were trying to make headway on health apps. On one of their projects, my team was tasked to rewrite a legacy .NET surgery prioritization system into an Angular 9 app for a provincial health organization. Their primary users would be hospital admin staff.
The app would take a combination of answers entered into an 8-part form with 120 fields to assess how soon a patient had to undergo surgery.
It's not as serious as a medical device, but impactful to patient health outcomes to the extent the app decides who gets a brain scan before whom.
Angular’s MVC structure meant service classes performed the passing of state between views.
Our form was doubly-dynamic:
- fields that were displayed depended on a question-answer model, basically running through
Formbuilder
- subsequent fields that were displayed were dependent on combinations of inputted values of other fields
Oh yeah, stakeholders decided that each field had live, oninput
validations that were dependent on the values of other fields.
Doesn't this sound like it's ripe for a state management solution?
Well, we didn't use one.
Instead, we used observables with RxJS to subscribe to streams of async event data, and used service classes to pass data between views.
What happens if when only use services to share state?
// question.service.ts
@Injectable({
providedIn: 'root'
})
export class QuestionService {
getPatientIntakeQuestions(): QuestionBase<string>[] {...}
getPatientProcedureHistory(): QuestionBase<string>[] {...}
getPatientEncounterHistory(): QuestionBase<string>[] {...}
}
Service classes get longer and longer with custom methods to check for specific conditions.
State was transient and could only be debugged through inline console logs
Where you had conditional adding of fields you'd have to loop through form groups to check whether an
AbstractControl
on a particular nested form group's form value had been interacted with.Conditional rendering and live validations relied on runtime checks against a JSON file with 280 combinations of custom form validation rules.
Business logic starts creeping into controllers, and lines blur for where to put functions that load and update state or cause side-effects.
We abused post and fetch requests to store any state change in the absence of a sink to hold any change in state
At the time, Angular didn't have standalone components and we didn't know about Angular Universal's capabilities of SSR.3 We made multiple API calls sequentially to fetch and populate form questions and previous answers after login, instead of server-side rendering the form statically.
Due to the length of the form, there was a requirement to save any answers up to point it had been filled. LocalStorage
wasn't an acceptable solution due to the sensitivity of patient health information. It's likely we posted the "auto-saved" versions at idle intervals to a different endpoint.
// dynamic-form.component.ts
export class DynamicFormComponent implements OnInit {
form: FormGroup;
constructor(private formBuilder: FormBuilder, private questionService: QuestionService) {}
ngOnInit() {
this.form = this.formBuilder.group({
patientIntake: this.formBuilder.group({
questions: this.questionService.getPatientIntakeQuestions()
}),
patientProcedureHistory: this.formBuilder.group({
questions: this.questionService.getPatientProcedureHistory()
}),
patientEncounterHistory: this.formBuilder.group({
questions: this.questionService.getPatientEncounterHistory()
}),
patientSurgeryHistory: this.formBuilder.group({
questions: this.questionService.getPatientSurgeryHistory()
}),
...// and on and on and on...
});
}
...
onInputChange() {
// check if a field has been dirtied
// check if a field is part of a formgroup that should have an instance added
// invoke live validation for form fields
// show the errors above form fields
}
ngOnSubmit(){
// loop through all forms
// show the form level validation errors above the form
}
onSubmit() {
// the actual form submission post request
this.formService.submitForm(this.form.value).then(...
// notify user of any response errors
)
}
}
App architecture wise, we used some weird hybrid between modules separated by domain and angular's "shared" folder structure.
Dynamic forms and reactive forms fulfilled the need to render different form data models, but this got tacky as soon as we needed to:
- conditionally render nested form groups or fields
- display previously entered information or conditionnally disable previously filled fields
- add another field instance to a form when it's edited or filled
- validate form fields based on the values of other fields
The disaster that arose from not using a state management solution: longwinded, overly imperative ("if-abc, else if xyz, else if jkl..."), and much harder to maintain and debug code.
III. The 7 year old shopping app that was never refactored
The old stack: Nuxt 2, Vue 2, VueX, Tailwind, Vercel, MySQL, Laravel
The new stack: Nuxt 3, Vue 3, Pinia, Tailwind, Cloudflare pages, MySQL, Laravel
In the past year, I've been working on migrating Vue 2 and VueX to Vue3 and Pinia in Nuxt 3 for an e-commerce app.
Imagine an app for boutique knick-knacks starts by hydrating a user store with all the info related to a user whenever they login.
At the beginning, the business only runs in Canada. It's good enough to have everything in one single store.
Later, this app decides to expand in the US and also sell American goods, and offer specific coupons and promotional discounts to American users.
Developers decide they need to create an order store to handle the possibility a user may shop in different currencies and payment methods (A contrived example, but not far from what I've seen in the wild).
The specific project team hurtles to roll out a new store for American orders, but still keep the original one to handle Canadian orders.
What could go wrong?
The store is split into modules that pertain to domain features, but it has a couple oddities:
Similarly named state properties and actions.
orderStore().confirmOrders()
andaccountStore.purchaseItems()
perform the same function, but they're in different stores. TheaccountStore
has anitems
property that tracksorder.data.items
in the cart pre-checkout, but so does theorderStore
fororderStore.items
in the cart, post-checkout!Duplicated state: while the account store would be populated with user-adjacent info right after login, the order store is updated whenever it's instantiated in American users' sessions.
Missing knowledge loops: Many features of the app still rely on the legacy
order.data.items
property, but theorderStore
'sorder.items
inventory needs to be displayed in US currency. Without cataloging and updating areas that rely on the old store, the display of the correct cart continues to falter.-
Getter abuse: The
accountStore
has getters that detail trivial pieces of user state that have been tacked on over time:-
isSubscribedToNewsletter()
is already denoted by the propertyuser.hasSubscribed
returned from the API call. -
isVegetable()
might be helpful for vegetarian users, just asordersWithoutPeanuts()
would for all cart items without peanuts, and might have been a feature request quickly implemented to serve those with allergies, but not needed by all users. These two would be better off as computed properties in auseDietaryRestrictions()
composable.
-
neither store was fully migrated to Vue 3 composition API.
And so, the more features added, the busier the team gets with patching and reconciling state in both stores 😱
Eventually, someone has to rip the bandaid off in order to move ahead:
retire the use of
accountStore
and relocate Canadian and American orders to show underOrderStore
-
If the separation is worth longterm sustainability, then 2 stores might co-exist independently with the same data:
- we can get the second store to subscribe to the first on load, then on state change, dispatch the actions in the second store.
- OR, we get rid of hydrating the first store and instead hydrate the second on load so we won't have to keep 2 stores' data in sync.
Why devs hate stores
Setup drudgery and boilerplate
The amount of code needed to set up a store, actions, reducers and dispatchers can be overwhelming for newcomers.
To be effective with stores, developers needed profiency with a framework's rendering mechanisms consider the tradeoff of strenuous setup and boilerplate for the benefit of shared state between views.
Every action had to be created and dispatched via the reducer, and any container view using the store had to be connected to it. I don't recall how many times I wrote mapStateToProps
and mapDispatchToProps
.
Redux is rather overkill in today's front end ecosystem if you're building an MVP, and as of late more have been using redux-toolkit or useReducer
to update slices of state.4
Premature optimization leads to higher maintenance efforts
Creating a store for every app feature can lead to unnecessary complexity and coupling. When multiple stores share the same data source, developers have to remember to keep states synchronized or remember which store depends on another. Premature code splitting can confuse teams and make maintenance harder.
The more stores you have, the more state you have to keep track of!
To store or not to store?
Using a store for state management is ideal when:
There is complex, domain-specific logic that needs to be diligently updated as part of a larger product or platform as a user navigates through different views.
It becomes unsustainable to manage state within one or two components and you find that you're passing the same data properties across multiple components. (aka. prop-drilling)
There's a need to present or update different types of data across different features of an app, or work with nested deep data structures to update the UI.
If there are multiple dimensions of state to track, a store may not offer enough granularity for defining exact state transitions. It may be worth looking at implementing a state machine (some libraries include XState or Immer)
For example, if you need interaction changes through multi-step forms, wizards or like the combinatories of form fields I previously described.
Caveat: I have not had to use above libs yet.
It's easy to get to a place where no single pattern is used, but someone somewhere, at some point is going to take issue with whether you use:
a provider that encapsulates state and actions for a component tree (the basis for Context API)
an observer-style event bus,
a singleton that can be updated from anywhere in the app (React Context and Vue composables might be lightweight ways to do this, but I have found that composables are not good replacements for behaviour you'd expect from singletons.)
Contemporary state management
Front end frameworks today offer lightweight options for state management.Leveraging out-of-the-box mechanisms will suffice for small to medium-sized apps.
React's hooks (useState
, useEffect
, useContext
) allow for easy encapsulation of reactive business logic. The Context API reduces prop drilling by making state accessible at any component level.
Vue's computed()
, ref()
, and watch()
functions, along with composables, achieve similar results to React hooks and context.
Newer options like signals in Svelte and Angular are also emerging.
Frameworks aim to:
- Reactively update the UI when data changes
- Subscribe/unsubscribe to data changes
- Manage side effects
- Distinguish between local, shared, and global state
Where you need it, it's probably wise to choose a strategy before entropy takes hold.
-
The contents of this post are based on my work experiences but I've changed exact details. ↩
-
React's fundamental principles promoted thinking about data moving through components in a unidirectional matter, and devs could easily control rendering and interactions with methods like
componentDidMount
andcomponentDidUpdate
. ↩ -
Wassim Chegham. "Angular Universal for the Rest of Us" https://medium.com/google-developer-experts/angular-universal-for-the-rest-of-us-922ca8bac84 ↩
-
Dan Abramov. "You Might Not Need Redux", Medium, Sep 19, 2016. ↩
Top comments (0)