DEV Community

Ayron Wohletz
Ayron Wohletz

Posted on • Updated on

Tech choices for web apps

The frontend ecosystem is vast and constantly changing. As developers, we want to make tech choices that will meet immediate requirements and hold up well over time. Based on what we have experience with, and what we know will work, our choices tend to converge to a set of defaults. These become our go-tos for when we need to get something done.

This post explains my default tech choices for building a single-page app (SPA).


React is my default choice for single-page app framework. It has great ecosystem and support -- e.g. most popular libraries, such as Apollo Client, choose to support React first. And Hooks have made React much more pleasant to work with.

React continues to dominate in the frontend ecosystem (seeState of JS 2020.) It's about a safe a bet as it gets in frontend today.


With React, I favor function components with Hooks over class components. Hooks let me easily encapsulate stateful logic. And I find them easier to understand and maintain than classes. Take a class component, convert it to Hooks, and you'll likely see a reduction in code volume and ceremony.

The ecosystem has moved towards Hooks too. For example, the excellent react-query library favors a hook interface.

Project creation

Instead of setting up Webpack, TypeScript, and so on from scratch, I use create-react-app (CRA). You'd have to have an extremely unusual use-case to set up Webpack config from scratch these days.

If you need a more advanced, opinionated setup that has server-side rendering, code splitting, and more, check out Next.js.

If you want a primarily static site, check out Gatsby.


TypeScript gives me significant benefits compared to raw JavaScript. I can work faster, refactor more confidently, model systems with types, take advantage of enhanced IDE analysis, strictly handle null and undefined, and, of course, catch errors at compile-time instead of runtime.

It's even more useful when working on a team. Instead of developers tracing through the runtime code to figure out what symbols represent, they can view the type. The TypeScript types are like executable documentation.

Full-stack static typing with TypeScript catches a whole class of breaking changes between API and frontend before they hit runtime. It's easiest to achieve if you use TypeScript with Node on the server side. Then you just share your types somehow — either a common dependency or put both client and server in a monorepo. It's wonderful to work with a full TypeScript stack.

For other languages, for example Java or C#, there are TS type generators available. Or if you use Swagger, you could generate a TypeScript API client from it with swagger-typescript-api.

State management

There are two types of state to manage in frontend: Client and server.

Client state lives solely in the browser. It's never stored on the server. Examples of client state are modal show/hide booleans, form inputs that haven't been submitted yet, or row selection status in a table.

Server state is stored on the server. The server is the source of truth for it. Examples of server state are user credentials or domain objects saved in the database. Server state tends to have common management patterns that every app needs, such as making network requests, caching responses, showing loading indicators, handling error responses, and so on.

For client state, I take useState as far as it will go without introducing a global shared state library. Locality is easier to understand than non-locality. I only introduce a shared state library if prop drilling becomes a burden, or I have distant siblings in the component tree needing to share state.

Sometimes I use useReducer in small subtrees of the component tree or independent components. I wouldn't recommend it for global state management though, because passing dispatch and state down through the whole tree becomes burdensome.

When I have client state that rarely changes, and I'm not already using a shared state library, I might use React context. For example, if the user logs in, and their username doesn't change afterwards, that could be a good use-case for React context. Just be warned though that every component that subscribes to the context will re-render when the context value changes. That can cause a performance problem for more frequently-changing data. Context isn't a good substitute for a state management library.

Redux has long been popular for managing both client and server state. I've stopped using it for server state. Handling asynchronous effects with Redux (e.g. network requests) gets complex. Thunk, saga, observable — either way, a typical project accumulates thousands of lines of code devoted to those common server state management patterns.

Instead of Redux, I now use react-query for server state. React-query encapsulates those common patterns behind a clean hook interface. On my projects, I estimate a 5-10x code volume reduction by using react-query (or, similarly, Apollo Client for GraphQL APIs) over Redux.

React-query works with any kind of API. If used with a GraphQL API, graphql-code-generator works with it.

If I need shared client state, Redux works fine, but I have started to prefer Recoil. Recoil reduces the code volume and ceremony needed to manage simple shared state. With react-query handling server state, there's usually only a little client state left to manage, and Recoil keeps that simple.

Server interaction

There are two main flavors of API that frontends consume: REST and GraphQL. For REST, I default to react-query. For GraphQL, I choose between react-query and Apollo Client. If you only have time to learn one, I'd go with react-query.

Beyond that, I've recently started using tRPC on experimental projects where I have a full TypeScript stack. tRPC offers an amazing development experience — full-stack static typing, no code generators, fast and lightweight. It's like using GraphQL minus the intermediate schema layer and code generators.


I default to react-router for routing. It seems like the most supported and popular router. It has been around for a while and has worked for whatever I've wanted to do so far.

Style libraries

For a conservative project, I default to Bootstrap. Bootstrap has served me well for years. It's a mature library, allows customization, takes accessibility into account, and has robust component libraries like react-bootstrap.

For new projects, I'm using chakra-ui. I can customize it much more than Bootstrap, and the ability to pass styles as props on components saves time and avoids boilerplate CSS classes.


Forms become a pain to manage with useState beyond a few inputs. If an app will have significant forms, you can save time by bringing in a good form library early.

I like react-final-form. It has everything needed for a fine form experience — features like custom form validation, the ability to keep extra props on the form values, easily plugging custom components in, reactively changing input values in response to other inputs (decorators), and tracking interaction states like touched, blurred, pristine, submitted.

I recommend creating a library of components that wrap your stock form components (e.g. react-bootstrap) in final-form fields. That way, you won't have to repeat those patterns over every form.

React-hook-form is another good form library. I have tried it several times but always ended up converting back to react-final-form. Mostly because I like final-form's ability to keep extra properties on the form values without needing a hidden form field for them. For example, items loaded from the database usually have an ID field, and it's convenient to keep that on the form value objects to identify them when the user submits the form.


For unit tests, I choose Jest. Its snapshot test feature is particularly useful, for both frontend and backend unit testing. And the community has built a bunch of presets, for example jest-dynalite for DynamoDB testing in Node.

For end-to-end UI tests, I default to Cypress. It does a pretty good job of automatically waiting so that you don't need explicit sleep statements everywhere.

To test components in a simulated browser environment, I use React Testing Library. It pairs well with Storybook via @storybook/testing-react.

To test components in a real browser environment, I do that manually with Storybook. Easily automating this kind of real-browser component test has been a bit of a gap in the ecosystem.

Cypress component tests looks like it will fill that gap. Cypress component tests are like UI tests, except they test a single component in isolation. It's like automating the testing that I do manually in Storybook. It's still in Alpha though, so be warned. I haven't used it yet for any large project.


I like to develop components in Storybook. Storybook lets me develop the component in an isolated, controlled environment. I don't have to fiddle around with the live app to try to produce a certain state of the component (e.g. loading or error states.) I can set up a story for that state directly.

A UI component is like a finite state machine. Storybook gives me a harness to invoke and work on every state that my component can take.

I have stories for components that make network requests and other side effects. I do this by setting up a mock server with msw and wrapping the stories in the needed context providers (e.g. routers, forms, clients.) We'll see this setup later in this book.


I configure a git commit hook with Husky runs the linter and runs the test suite. That way I can't forget to run those and have the build fail in the CI/CD pipeline. This is particularly useful when working on a team.


I use Lodash in almost every project, except for the most trivial.

Browsers have implemented some of the Lodash functions natively. For example,, Array.filter, and Array.reduce. However, Lodash still offers dozens of useful functions that have no native implementation. It's worth getting familiar with what it offers.

Even with native implementations, I still prefer the Lodash versions of map, filter, and reduce, as they handle null and undefined by treating them as empty arrays. That tends to be a more useful default. The native methods throw a runtime error. That means I constantly have to check and convert to empty array, like (myArray ?? []).map(…) and so on.


Usually I just use CRA's default lint rules. If working on a team, it may worth it to set up more extensive formatting and linting. For example, you can avoid whitespace noise in code reviews by setting up Prettier and having it automatically apply. Otherwise, everyone's IDE will auto-format a bit differently and those formatting changes will pollute diffs.

Updated 2/26/2022: Removed extra words, updated some choices

Discussion (0)