ionicons-v5-a Back
The best React tech stack in 2023
react

9 min read

The best React tech stack in 2023

About the React stack that worked out through a bunch of years of development, feels good nowadays and seems like will stay here for a quite long time.


Frontend technologies are advancing rapidly, making it challenging to keep up with the wide range of frameworks available. Having worked with React for over 8 years and experimenting with various setups, I can confidently recommend the approach described in this article as a highly recommended choice for 2023.

Build tool: Vite

The React team no longer recommends using create-react-app. With React evolving towards server-rendered applications, the official Start a New React Project page suggests alternatives like Next.js, Remix, and Gatsby, but doesn’t mention create-react-app anymore.

Pick Vite if:

  1. You don’t need SSR and SEO optimizations for your React application.
  2. You don’t want to learn Next.js and all this stuff.
  3. You have an exsisting project with CRA or other tools and want to get faster development experience.

More about my experience of migrating from CRA to Vite here.

Server layer: react-query

After years of struggling with merging backend responses with the app state, finally a solution was found. This is where react-query comes into play.

If we consider the app state as two distinct parts, namely the client state and the server state, separating them brings a significant level of clarity to the development process. Therefore, if you haven’t tried React Query yet, I highly recommend doing so.

Before:

// example of client state
const [expanded, setExpanded] = useState(false);
 
// example of server state
const [isFetching, setIsFetching] = useState(false);
const [movies, setMovies] = useState<string[]>([]);
 
const loadMovies = async () => {
  setIsFetching(true);
  const response = await fetch('http://example.com/movies.json');
  const movies = await response.json();
 
  setMovies(movies);
  setIsFetching(false);
};

After:

// example of client state
const [expanded, setExpanded] = useState(false);
 
// example of server state
// useQuery provides a lot of states like isLoading, isFetching, isError etc
// all the data are cahced and available globally by ['app-movies'] query
const {
  data: movies,
  isLoading,
  refetch,
  clear,
} = useQuery(
  ['app-movies'],
  () => await fetch('http://example.com/movies.json').json(),
);

By utilizing the abstraction of the server layer, we can effectively address a significant challenge related to storing the data received from the backend in our application state. This applies to various state management approaches (React state or Redux state, for example).

Typescript

I highly recommend using TypeScript everywhere, even in small projects. It simplifies development and aids in producing bug-free software.

Pro tip: Never let TypeScript block you from seeing the result in the browser. Developers should have the ability to verify that the code works first, and then address any TypeScript errors.

No more words, just learn and use TypeScript 😐.

Forms: react-hook-form

It’s hard to imagine an app without any forms, and handling forms in React, especially dynamic ones, can still be challenging without additional tools. From my experience, the best option is react-hook-form.

Here is an example of how React Query and React Hook Form can work together, providing all the necessary api for the entire form.

interface IProps {
  defaultValues?: LoginData;
  onSuccess?: () => void;
}
 
export default function useLoginForm(props?: IProps) {
  const { handleSubmit, control, reset, watch } = useForm<LoginData>({
    defaultValues: {
      email: '',
      password: '',
    },
  });
 
  const mutation = useMutation({
    mutationFn: ({ email, password }: LoginData) =>
      LoginService.login(email, password),
    onSuccess: () => {
      history.push('/app');
    },
    onError: (err) => {
      // on error
    },
  });
 
  const submit = (data: LoginData) => {
    mutation.mutate(data);
  };
 
  return {
    ...mutation,
    onSubmit: handleSubmit(submit),
    control,
    reset,
    watch,
  };
}

Example of usage in the component:

const LoginForm = () => {
  const { control, onSubmit, isLoading: isLoggingIn, watch } = useLoginForm();
 
  return (
    <form onSubmit={onSubmit} className="w-full">
      <Controller
        name="email"
        control={control}
        rules={{
          required: 'This field is required',
          validate: (v) => EMAIL_REGEX.test(v) || 'Wrong email format',
        }}
        render={({ field, fieldState }) => (
          <TextField
            {...field}
            error={!!fieldState.error}
            helperText={fieldState.error?.message}
            type="text"
            placeholder="Enter email"
          />
        )}
      />
      <Controller
        name="password"
        control={control}
        rules={{
          required: 'This field is required',
        }}
        render={({ field, fieldState }) => (
          <TextField
            {...field}
            error={!!fieldState.error}
            helperText={fieldState.error?.message}
            type="password"
            placeholder="Enter password"
          />
        )}
      />
    </form>
  );
};
 
export default memo(Registration);

Design system: Tailwind CSS

I’ve written extensively about Tailwind. Once you get used to it, you’ll never go back to writing traditional CSS. You can learn more about Tailwind and migrating exsisting project (even with MUI) in this article.

For basic functionality, you can consider utilizing the main controls in your app by using HeadlessUI. It offers a range of options that can be useful in implementing common UI components.

To move faster consider using MUI + Tailwind with Tailwind design system in priority.

Router: react-router-dom

If you decide to stick with client-side rendering and choose not to use Next.js, you will need to select a router solution.

React Router is still the most stable and popular solution.

Another alternative can be TanStack Router. It is currently in beta, so use it at your own risk. However, it’s worth mentioning that this new library addresses many of the TypeScript-related issues and others found in react-router-dom.

You can check the comparison table of TanStack Router and React Router here.

State Management: Zustand

Yes, not Redux. Zustand is a state management library for React applications. It is a minimalistic and lightweight alternative to more complex state management solutions like Redux or MobX. Zustand leverages React’s Context API and hooks to provide a simple and intuitive API for managing state in your components.

One of the key features of Zustand is its focus on simplicity and performance. It promotes a functional programming style and encourages the use of immutable data. Zustand also supports devtools integration, making it easier to debug and inspect your application’s state.

With Zustand, you can create stores that hold your application’s state and use hooks to access and update that state from your components. It provides a flexible and scalable approach to state management, allowing you to organize and share state across your application efficiently.

import { create } from 'zustand';
 
type Store = {
  count: number;
  inc: () => void;
};
 
const useStore = create<Store>()((set) => ({
  count: 1,
  inc: () => set((state) => ({ count: state.count + 1 })),
}));
 
function Counter() {
  const { count, inc } = useStore();
 
  return (
    <div>
      <span>{count}</span>
      <button onClick={inc}>one up</button>
    </div>
  );
}

Learn How Zustand stacks up against similar libraries here.

Testing: Cypress

To be completely honest, in my real-life projects, I have never found unit testing to be particularly useful. Therefore, let’s focus on discussing end-to-end (E2E) testing instead. After experimenting with various frameworks, I have found Cypress to be the best in terms of overall testing and developer experience.

Conclusion

Ultimately, the best React stack will depend on the specific requirements, team expertise, project complexity, and other factors unique to your situation. It’s essential to evaluate and select the stack that aligns best with your project’s needs, developer skill set, and long-term maintainability.

I suggest trying out each library mentioned in this article if you haven’t already. And always evaluate the pros and cons that each library brings to the table before starting using it or migrating to it.


Dzmitry Kozhukh

Written by Dzmitry Kozhukh

Frontend developer


Next

Sunsetting Redux Saga
react Sunsetting Redux Saga

Uncovering reasons to move away from redux-saga complexity for simpler and more maintainable state management in React applications.

3 min read