If you have ever used React or Next.js or any frontend framework, chances are that you have worked with some of the following concepts:
In this tutorial, we will break down the state of modern frontend development with React and Next.js.
Let's explore the practical best practices for building fast, maintainable, accessible web apps in 2025. From state management to hybrid rendering, code splitting, caching, server actions, and accessibility,
Let's dive in!
Here is the complete video for this tutorial.
When you think of State Management, a familiar question comes up, "What kind of State Management library do we need?". This follows with answers such as Redux, Zustand, etc.
However, you may not need a State Management library for your project!
Let's discuss the reasons why you may not need a state management library.
Shruti Kapoor, a former Frontend Engineer at Slack who was building a web app for Paypal at the time used Redux for caching user forms.
Later on, they added GraphQL because they were sending a huge amount of data to users. However, because GraphQL needed Apollo client which also came with Apollo cache, they got caching for free!
What ended up happening is that Redux became useless as such ended up with a small bundle and not having to show Redux to the client.
It is important to think about the kind of data are you sending over to the client.
Libraries for state management also get bundled and sent to the client which they don't need. In other words, "Don't give me your garbage".
We also don't want users to wait, so we want to be able to send small bundles to the client and not excessive data.
Say you don't want to use a state management library, how do you implement state management in React?
For most Single Page Applications (SPA), the 3 following React state management hooks are enough:
useState
: A React Hook that lets you add a state variable to your component.useContext
: A React state management Hook that lets you read and subscribe to context from your component.useReducer
: Similar to useState
, but it lets you move the state update logic from event handlers into a single function outside of your component.If you have to share data between components, you can then add external libraries that are lighter in weight than Redux. They include:
Here is a Flowchart to show which state management library to use when working on a project or application.
If you have a small application, you can use useState
or useReducer
If you have a lot of complexities and need to share data between components, that is when you need external state libraries such as Jotai.
If you don't have server integration or you have to fetch a lot of data, Zustand and Redux are great options.
If you want to limit the data that comes to the client but want to perform auto-caching, TanStack is a great option.
Scenario | Recommended Solution |
---|---|
Small application | useState or useReducer |
Complex state sharing between components | Jotai |
No server integration or need to fetch lots of data | Zustand or Redux |
Limit client data and enable auto-caching | TanStack Query |
Here are some of the best practices for state management in a React application.
In React applications, you need to make sure you keep states as close to the component that uses the states at all times.
Take a look at the code below:
1// BAD EXAMPLE: State too high in the component tree
2function App() {
3 // state lives too high in the component tree
4 const [searchTerm, setSearchTerm] = useState("");
5 const [selectedItem, setSelectedItem] = useState(null);
6
7 return (
8 <div className="app">
9 <Header /> // re-renders when state changes
10 <Sidebar /> // re-renders when state changes
11 <MainContent
12 searchTerm={searchTerm}
13 setSearchTerm={setSearchTerm}
14 />
15 </div>
16 );
17}
The code above is a search model with a header, sidebar, and main content. The main content needs to know the search term and the searched data.
Pause for a minute and tell what is wrong with the code.
The problem with the code above is that anytime searchTerm
changes, the whole component re-renders.
Also, Header
and Sidebar
do not need the searchTerm
state. So we are re-rendering components based on data they don't even need.
The best practice would be to move the states to only the component that needs it, in this case, the MainContent
as shown in the code below.
1// GOOD EXAMPLE: State colocated with the component that uses it
2
3// Main Component
4function MainComponent() {
5 // Search State is now moved to the Components that Need it
6 const [searchTerm, setSearchTerm] = useState("");
7 const [isSearchFocused, setIsSearchFocused] = useState(null);
8
9 return (
10 <div className="main-content">
11 <SearchBar
12 searchTerm={searchTerm}
13 setSearchTerm={setSearchTerm}
14 isSearchFocused={isSearchFocused}
15 setIsSearchFocused={setIsSearchFocused}
16 />
17 <SearchResults searchTerm={searchTerm} />
18 </div>
19 );
20}
21
22// App component
23function App() {
24 return (
25 <div className="app">
26 <Header />
27 <Sidebar />
28 <MainContent />
29 <Footer />
30 </div>
31 );
32}
In the code above, we have now moved the states to the MainComponent component that only uses it. Now, the App component is cleaner. When search state updates, Header and Sidebar components are not re-rendered.
Although, this goes against the philosophy of "Lift State Up", you want to lift the state up, but only as close to the component as possible.
Imaging you have a User component that fetches user data by using the user ID as shown below:
1function UserProfileBad({ userId }) {
2 // Bad: dependency on the entire user object
3
4 const [user, setUser] = useState(null);
5
6 useEffect(() => {
7 // fetch the entire user object
8 fetchUserData(userId).then((userData) => {
9 setUser(userData);
10 });
11 }, [userId]);
12}
In the code above, we are managing the entire userData
object. This means that whenever userData
state changes, all components will re-render. This is a wrong approach.
We only want to extract items or data that we need which are userName
, userStats
, activities
, and friends
. So we have to create states for each of these data.
This way, our app doesn't need to depend on the entire userData
object which is a lot of state to watch for and that causes re-render.
Here is a good way to manage states granularly.
1// GOOD PRACTICE: Granular state and selectors
2function UserProfileGood({ userId }) {
3 // Split state into meaningful pieces
4 const [userName, setUserName] = useState("");
5 const [userStats, setUserStats] = useState(null);
6 const [activities, setActivities] = useState([]);
7 const [friends, setFriends] = useState([]);
8
9 useEffect(() => {
10 // Fetch only what's needed or extract specific pieces from resp
11 fetchUserData(userId).then((userData) => {
12 setUserName(userData.name);
13 setUserStats(userData.stats);
14 setActivities(userData.recentActivities);
15 setFriends(userData.friends);
16 });
17 }, [userId]);
18}
When we add multiple libraries and states using Redux, Jotai, etc. we shouldn't have the same data present in more than one place.
For example, the same data should not be present in both the Context API and the Redux store.
The whole approach to data fetching is "Don't make me wait". If you are loading any component data, ensure it loads as fast as possible.
Ensure that users get the next interaction as fast as they can because this determines how fast or slow they perceive your app.
These are some techniques to keep in mind when fetching Data.
Let's talk about rendering Strategies.
There are 4 different rendering strategies.
The Next.js rendering strategies above can be confusing. Here is an illustration to better understand them.
Rendering is how you put food on the table.
Learn more about Server-Side Rendering vs Client-Side Rendering and SSR vs. SSG in Next.js.
The recommended and best practice for rendering is known as Hybrid rendering.
Hybrid rendering is when you combine different rendering strategies for different purposes.
Examine the code below.
1return (
2 <>
3 {/* Server Component with product details (SSR) */}
4 <ProductDetails product={product} />
5
6 {/* Static part pre-rendered (SSG) */}
7 <SimilarProducts products={similarProducts} />
8
9 {/* Client Component with dynamic content (CSR) */}
10 <Reviews reviews={reviews} />
11 </>
12);
Let's explain the code above based on the rendering strategies we mentioned.
getServerSideProps
in Next.js to achieve this.getStaticProps
in Next.js to achieve this.useEffect
hook comes in.useSWR
To take a step further, we could add useSWR
to the useEffect
hook. This is where the server comes in to make sure the reviews are up-to-date. This means "stale-while-revalidate", a strategy to first return the data from cache (stale), then send the fetch request (revalidate), and finally come with the up-to-date data.1{/* Static part pre-rendered (SSG) */}
2<SimilarProducts products={similarProducts} />
3
4getStaticProps() {
5 return {
6 props: {},
7 revalidate: 60
8 }
9}
There are other strategies you can use to render content. They include:
Let's learn how to optimize a Next.js application. Here are some tips on how to optimize your application.
In some cases, when you start building out an application without measuring performance, you will want to start using some hooks like useMemo
and useCallback
simply because you think a component is slow.
However, how do you know you have improved performance when you haven't monitored the current performance? Or, how then do we monitor the performance of an app?
We can use Core Web Vitals. Let's see what they are.
Core Web Vitals are three metrics (LCP, INP, and CLS) that measure the performance of a web application.
Core web vitals can be accessed through the following:
<Profiler>
component. Also, this helps you to know where the bottlenecks are and where to use some React hooks like the useMemo
and useCallback
After understanding which components are slow in your app, you can go ahead to use useCallback
and useMemo
to memoize your components. And if you are using React 19, you can use React Compiler which has a plugin and does auto memoization.
There are other ways of performing optimization as shown in the image below.
The YouTube clone below is unoptimized and needs optimization. You can try it out here.
Here are the issues and their possible solutions:
Let's start with the first visible issues.
Here are the solutions
1. Use Next.js <Image/>
component with loading='lazy'
.
2. Use Dynamic imports.
3. Use responsive image sizes.
4. Use CDN for asset delivery.
5. Implement infinite scrolling.
6. Load videos in chunks.
Some of the content in the YouTube clone can be generated as static content and delivered through a CDN.
getStaticProps
.generateStaticParams
generateMetaData
to generate content from out of the server for SEO.We can server-render some data and more.
useSWRInfinite
or TanStack query for infinite Scrolling.Here are some best practices for Code Maintainability.
If you are building an app for the web without thinking about everyone, you are unconsciously excluding people from the web.
Here are some best practices for web accessibility in React and Next.js .
<button>
element.Here is the complete video for this tutorial:
In this tutorial, we have broken down the state of modern frontend development with React and Next.js.
We explored practical best practices for building fast, maintainable, accessible web apps in 2025. From React state management hooks and why you may not need state management libraries to hybrid rendering (SSG, SSR, CSR, and ISR), code splitting, caching, server actions, and accessibility,
The theme of this tutorial is "Don't make me wait". Don't make users wait for data to come in, a page to render, or layouts to shift. And don't give users of your app garbage.
Theodore is a Technical Writer and a full-stack software developer. He loves writing technical articles, building solutions, and sharing his expertise.