A sensible Front-end stack for Production applications · 2023 edition
In 2023 more than ever the front-end ecosystem is thriving and we have amazing new tools coming up. TypeScript is very mature, meta-frameworks are pushing the industry forward, and we're spoiled for choice of component libraries, CSS frameworks, build and deploy tools, etc, etc.
Having a lot of options is great for keeping the industry and the ecosystem moving forward. It's also very cool when you're tinkering, but it can be kind of daunting when you're working on production-grade applications.
We sometimes tend to over-rely on third-party code. There is an NPM package for everything and it's tempting to just go for it. Having a lot of external dependencies can be a problem.
As front-end engineers, it is our responsibility to balance between relying on as few dependencies as possible while having enough tools to be productive.
Here are the main criteria I use to guide my choices, in order of importance:
- Use tools that are stable and considered production-ready.
- Use tools that are actively maintained - no dead code.
- Avoid bloat. Use only the minimum dependencies that you can get by with.
- Keep cognitive load low. Be cautious when introducing dependencies that require a specific mental model. They can be taxing on your team, especially when onboarding new people.
Me and my team at LatticeFlow use the same approach to define our front-end stack and we've been pretty happy with the results so far.
The following is my pick for what I consider to be a great front-end stack for production applications.
TypeScript
TypeScript is the backbone of the stack. It's always there in the background and lets you know if there is something wrong. It helps you keep our code clean, maintainable, and understandable. It makes refactoring code much less painful.
The first time someone suggested using TypeScript in production at a company I worked at was in 2018. I voted against it - I thought it was not mature enough and the ecosystem wasn't there. We ended up adopting it, and we had some troubles. Libraries either missed typings, and we had to define our own, or there were a lot of outdated and plain wrong typings from DefinitelyTyped. Back then, it felt more like a burden and a chore than a helpful tool.
Things have changed a lot! TypeScript is my go-to choice and has been for at least the past three years. If a library doesn't have typings (which is a very, very rare occasion these days), I tend to dismiss it.
I suggest using TypeScript and upgrading often. It has been a wonderful experience working with it (especially once you get used to the tsconfig
).
React
Old Faithful. React is still a great choice for application development.
I admit it's not the new kid on the block and doesn't have a lot of the new features of something like Qwik or SolidJS. However, React is solid (no pun intended 😁), dependable, and has a great ecosystem of tools and supporting libraries. It's also very easy to hire people who have experience working with it.
If you write Functional Components (and you should), I would say React is not the easiest to learn, but it's not hard either. There isn't a lot of custom syntax. JSX/TSX is very intuitive to most people. Most of the time, you use 5 hooks and a couple of other functions. Everything is well-documented, and there are a ton of examples online.
React is, in a (maybe a bit poetic) sense, the heart of the stack:
The decision to use React affects how we organize our entire codebase, how to think about and define abstractions, and how we interact with the people1 who use our product.
Oh, and React Router is a no-brainer.
Alternatives to React
If you require Server-Side Rendering, the obvious choice is Next.js. Next's recent transition from Pages Router to App Router has been a bit controversial, but that doesn't change the fact it is still a great choice. I'd advise against using App Router for production-grade apps for now - I've had some issues with it on smaller projects and it doesn't seem stable enough yet.
I still think Vue and Nuxt are viable alternatives, if your team prefers that. Most of the tools I talk about work or have some sort of alternative in the Vue ecosystem. If you had asked me two or three years ago, I would have said Vue is better than React. Nowadays I think it has too much custom syntax for too little added benefit.
If you ask me today, the future is either in React (if they keep up the pace, modernize, and embrace new concepts - like signals), or something new like SolidJS.
TanStack Query (formerly react-query) and React Context
TanStack Query has been an absolute joy to work with. It is TypeScript-native, has a great Hooks API for React, and strikes a great balance of sensible defaults and customizability.
The documentation is great, and there is a great series of tutorials/articles by the creator of the library.
I recommend embracing TanStack Query for both query management and caching of API responses. It introduces a bit of cognitive load with the caching mechanisms (e.g., cacheTime
vs staleTime
), but this is an area I'm okay to spend some of my brain cycles on.
Keep API-related state in TanStack Query cache. Keep other shared state in either global React Context(s), Outlet Contexts, or LocalStorage. Don't be afraid of a bit of prop drilling.
Oh, and if state is confined to a single component, keep it in that component. I know this may seem obvious, but I've seen people get it wrong enough times to warrant a callout.
To be honest, describing a good solution for global state management is a longer topic. I'll try to do it justice in a future article.
State management libraries
The elephant(s) in the room are the state management libraries: Redux, Jotai, and Recoil.
To be honest, I have never liked Redux. It has always been too boilerplate-y and unnecessarily complicated for me. It's the reason I was put off by React for a long time. It seems like every React tutorial in 2016 started with "let me tell you about reducers."
The atom-based libraries (like Jotai and Recoil) simplify things a bit. However, I still find they lead to spaghetti on your plate. They encourage, and almost require you to start putting as much state as possible in them. Junior engineers will typically make the mistake of reaching for atoms even for component-private state.
I think that with a large codebase, unless you have religiously defined conventions (which would increase cognitive load), atoms can easily get out of hand and the code can become a mess. All components start to depend on some partially available or global context, and you become a detective (or archeologist) of "how was this value computed through 5 layers of atoms and selectors."
I believe TanStack query strikes a great balance of usability and usefulness, introducing cognitive load only where it's needed, and providing a great developer experience.
CSS Modules and classnames
Yes, you read that right - no SCSS.
Chances are, if you need to do something fancy with SCSS, you're doing something wrong(-ish) and can encode this complexity somewhere else. I'm not saying there are no legitimate use cases for SCSS, but more often than not, what is done via if
, for
, or modules can be refactored as separate React components.
I recommend you rely on CSS variables (both on a global and component level) and use simple CSS modules.
The only big piece missing from SCSS (and I do really miss it) is rule nesting. You can get it in the form of a PostCSS Plugin or wait a bit for it to become part of the CSS standard.
A note on CSS Frameworks
I am not a fan of CSS frameworks for large production apps. I see their value for rapid prototyping, but as your codebase grows large, they can become more of a hindrance than a help. Once you have a growing front-end team and your product starts to define its own design language, I would advise against using them.
CSS frameworks also score low on the "keep cognitive load low" criterion. You have to learn their conventions and always keep them in mind when writing code.
Unstyled Components
First of all, a disclaimer - I have not used Unstyled Components in production, so take my advice here with a grain of salt.
However, I have looked at and experimented with some libraries. I really like Adobe's React Aria. I think this is the future.
Styled component libraries are great when you're starting out and need to do fast prototyping. But as your company grows and your product matures, you'll start wanting to define your own design language. You'll also eventually need custom components that you have to build on your own anyway.
Once these two factors are present, people start contemplating switching from a ready-made solution to an in-house one.
Most front-end teams start implementing components from scratch when they transition to their own component library.
The problem with that is that there are a bunch of functionalities and accessibility features that you have to implement, and you're bound to miss something.
This is where an Unstyled Component library can shine. Don't worry about implementing a button; just provide your own style on top of something that already has the basics nailed down.
MirageJS
I cannot emphasize this enough - Mirage is a-ma-zing!
It is probably the least popular tool on this list, so if you haven't heard of it - Mirage is an API mocking library. It intercepts HTTP requests and mocks the responses. It also creates a local in-memory database of objects that your mock responses can use.
Essentially, it allows you to simulate your app's entire back end and state with a very manageable amount of code. You can build full CRUD - your mocked responses will be able to create, edit, and delete objects from the mock database.
Mirage is proving to be an indispensable tool for development. The entire team can come together and discuss the product and define an API. After that, front-end engineers can split off, define the mocks, and do the entire implementation without having a real API server.
It is great for testing too - you can spin up a partial mock back-end server and mock the responses on a test-by-test basis.
Mirage also helps my team build interactive prototypes and gather feedback quickly. We can implement a new feature with a mocked API, give it to people to test and play around, and see if they have feedback about the UX.
React Testing Library
React Testing Library is a no-brainer for unit tests. It is fast, easy to get into, and most importantly - it encourages (and almost forces) you to use meaningful selectors when querying for elements.
You can get a lot of accessibility improvements just by writing the proper selectors for tests.
Prettier and ESLint
Now, I know what you're thinking - “duh, of course, everybody uses ESLint”. And yeah, you're right! But I wanna use this to bring up the fact that I believe Prettier and ESLint are crucial when working in a team.
My experience has been that most front-end engineers are quite opinionated (and I do admit I'm also sometimes guilty of this). I think we have to acknowledge it. Everybody has their preferred way of writing code - spaces, braces, quotes, alignment, etc, etc, etc.
I've been in front-end team discussions where people argue about code formatting for 2 hours. I never want to be in a meeting like that ever again.
I'm now lucky to work in a team where we don't put focus on discussions like this and we reach a consensus quickly. I gotta give some of the credit for that to the tools.
Prettier and ESLint enforce a common ground. Nobody is perfectly happy, but everybody has a compromise they can work with. It is what it is - you'll never have a massive argument in PR comments about style.
And yeah, I admit, sometimes arguments will arise about a specific ESLint rule or Prettier config… but you should be able to quickly resolve these by having everyone cast their vote and moving on. There's a lower chance of something becoming a long-winded religious argument. Most of the time discussions will be about a specific rule: “hey folks, how about increasing print width from 80 to 100?” → vote → change (or don't).
Notable mentions
Ramda
I don't think there's much more to say here. Pick whichever library you prefer: Ramda, Lodash, or RxJS.
You could also embrace the fact that probably you-dont-need-lodash-underscore. However, this may be too optimistic for production. Call me overly defensive, but I would still rely on the thousands of hours of work put into any of those libraries.
That said, my current choice is Ramda. It has cleaner API design and makes it easier to compose functions.
(Optional) date-fns or Day.js
When choosing a date library, consider you might not need a date library at all.
Here's some thoughts:
- If you haven't already, let go of Moment.js, it is time.
- Day.js is a decent alternative and uses an interface similar to Moment.
- date-fns is smaller and more performant.
I recommend going with date-fns, but I don't think you can go wrong with either.
(Optional) Zod
It feels like Zod has taken the front-end world by storm. It is proving to be a great tool - small, lightweight, TypeScript-native.
If you need some additional schema validation, especially when working with external data sources and services, I wouldn't blame you if you reach for Zod.
One thing you may wanna be cautious about is performance. Zod has improved this drastically in the past year, but it is something to think about.
There are lighter-weight alternatives like myzod if you only need schema validation.
Vite & Vitest
This is where I'll stray a bit from the “production-ready” criterion and suggest something that's not as battle-tested, as say, Webpack.
Vite still has some quirks to iron out, but the improved developer experience you'll get from it is worth spending some time tweaking it to make it work.
That said, I wouldn't think less of you if you went with Webpack. It is still a great and very safe choice. If build time is important to you, Webpack could be the better choice.
Conclusion
That's it! And this is the important part - I don't see this list as a starting point, this is it.
Of course, if you need something specialized on top - like a charting library or analytics tools - that makes perfect sense and definitely go for it.
But as far as the basics go, this should be enough.
Your dependencies will end up looking like this:
"dependencies": {
"@tanstack/react-query": "",
"classnames": "",
"date-fns": "",
"ramda": "",
"react": "",
"react-aria": "",
"react-router-dom": "",
"zod": "",
},
"devDependencies": {
"@faker-js/faker": "",
"@tanstack/react-query-devtools": "",
"@testing-library/react": "",
"@types/ramda": "",
"@types/react": "",
"@types/react-dom": "",
"@typescript-eslint/eslint-plugin": "",
"@typescript-eslint/parser": "",
"@vitejs/plugin-react": "",
"esbuild": "",
"eslint": "",
"eslint-plugin-prettier": "",
"eslint-plugin-react": "",
"miragejs": "",
"prettier": "",
"typescript": "",
"vite": "",
"vitest": ""
}
… plus or minus some dev plugins. I think that's a very manageable package.json
.
Hope you found this article useful! If you did, please let me know by pressing the heart below! Thank you!