Skip to main content

Command Palette

Search for a command to run...

Data Normalization in Frontend

Updated
6 min read
Data Normalization in Frontend
S

I'm a Software engineer with over 5 years of experience working with React JS, the most popular JavaScript UI library.

Exploring the world of full stack, system design, including AI.

If you've ever built a real React or any JavaScript framework app, you might know this problem: you change a user's name in one place, but the old name still appears in other parts of the screen.

To fix this, most developers start using long and messy loops (.map() or find()) to find and update data everywhere. It quickly becomes a problem to keep everything in sync.

So, in this blog post, I will discuss the normalization technique that can help us better organize API response data in FE.

We learned about "Normalization" in databases and often think it's only for backends. However, today's frontend apps are much larger and more complex. We are now facing the same problem that database experts solved years ago. This post is to motivate you to stop just putting API data into simple arrays and start organizing it like a proper database. This change makes your app faster and much easier to maintain.

Let’s see those techniques…


The Main Problem when using Array

When we receive a list of data from an API, our first instinct is to save it in a simple array. But arrays are actually quite bad for updating and looking up data.

Imagine you have separate lists for Messages and Users. To show the username next to a message, you have to search for the user every single time:

const [messages, setMessages] = useState([
  { id: 'm1', text: 'Hello!', userId: 'u1' },
  { id: 'm2', text: 'I am Subrato', userId: 'u1' },
  { id: 'm3', text: 'I am Anna', userId: 'u2' },
]);

const [users, setUsers] = useState([
  { id: 'u1', name: 'Subrato' },
  { id: 'u1', name: 'Anna!' }
]);

// ⚠️ THE PROBLEM: A loop inside a loop
return (
  <div>
    {messages.map(msg => {
      const user = users.find(u => u.id === msg.userId); // Searching...
      return <p>{msg.text} by {user?.name}</p>;
    })}
  </div>
);

The result? Every time the screen updates, your app is running a "search" over and over. If you have 100 messages and 100 users, those are 10,000 checks happening in the background. This is why complex UIs often feel laggy or "heavy."


The Solution: Create a "Mini-Database" on the Frontend

Instead of searching through arrays, we should store our data in a Hash Map (an object where IDs are the keys). This is what we call Normalization.

We split our state into byId (the data) and allIds (the order). Most professional frontend teams converge on a structure that separates Data Items from their Order. We call this the byId / allIds pattern. This is the same pattern that Zustand and redux uses in the background.

const [state, setState] = useState({
  users: {
    byId: { 
      'u1': { id: 'u1', name: 'Subrato' },
      'u2': { id: 'u2', name: 'Anna' }
    },
    allIds: ['u1', 'u2']
  },
  messages: {
    byId: { 
      'm1': { id: 'm1', text: 'Hello!', userId: 'u1' }, 
      'm2': { id: 'm2', text: 'I am Subrato', userId: 'u1' }, 
      'm3': { id: 'm3', text: 'I am Subrato', userId: 'u1' }, 
    },
    allIds: ['m1', 'm2', 'm3']
  }
});

// ✅ THE FIX: Instant lookup (O(1))
const user = state.users.byId[message.userId]; // No searching needed!

Why this is better?

  • Instant Lookup (O(1)): You don't need .find(). You just say users.byId[userId]. It’s instant, no matter how much data you have.

      const editMessage = (id: string, newText: string) => {
        setMessages(prev => ({
          ...prev,
          byId: { ...prev.byId, [id]: { ...prev.byId[id], text: newText } }
        }));
      };
    
  • Auto-Sync: Since every message just has a userId string, when you update the user in the users.byId map, every message using that ID is instantly updated.


When Should You NOT Normalize?

You don't have to be a "perfectionist." Normalization adds boilerplate. If your data is:

  • Read-only: (A "Terms of Service" page).

  • Single-use: (A simple profile header).

  • Small: (A list of 5 tags).

...just keep it as an array. Normalization is for complex applications where the same data appears in multiple places and changes frequently.


Why Frontend need "Normalize" Again?

Usually, databases are already normalized, why do we have to do the work all over again on the frontend? Why doesn't the data just stay "perfect"?

The problem is the API layer. Between your clean database and your React app sits on a transport layer (REST or GraphQL). As a frontend developer, you often don't control the shape of the data hitting your app. You might receive:

  • Deeply Nested Objects (Denormalized): Great for rendering a specific page in one go, but a nightmare to update (e.g., updating a user's name requires nesting deep into a "comments" array).

      {
        "id": "m1",
        "total": 500,
        "user": { "id": "u1", "name": "Subrato" }
      }
    
  • Flat Arrays: Safer to handle, but force you to run expensive .find() loops to connect related pieces of data.

      {
        "users": [
          { "id": "u1", "name": "Subrato" }
        ],
        "messages": [
          { "id": "m1", "text": "Hello", "userId": "u1" }
        ]
      }
    
  • Denormalized Arrays of object (JSON): APIs often "join" data together (denormalize it) just to make it easier to send over the internet in a single request.

    ⚠️ Important: This often produces nested-looking data, but the intent is different.
    The structure may be nested or flat, but data is duplicated.

    Flat based

      {
        "id": "o1",
        "total": 500,
        "userId": "u1",
        "userName": "Subrato"
      }
    

    or nested-looking:

      {
        "id": "o1",
        "user": {  "id": "u1", "name": "Subrato" }
      }
    
  • Normalized Entity Map shape: Data is split by entity type and stored in ID-keyed maps.
    This is not a JOIN response — it’s a state representation.
    Note: This is not a query-friendly format for backends, which is why APIs rarely send it directly. Join query is difficult when using this shape.

      {
        "orders": {
          "o1": { "id": "o1", "userId": "u1" }
        },
        "users": {
          "u1": { "id": "u1", "name": "Subrato" }
        }
      }
    

If you are designing the backend, default to Flat Arrays with IDs. Why? Because it’s the most "honest" way to send data. It protects your database from expensive JOIN operations (which can slow down the API) and lets the frontend decide how it wants to consume the information.

Following are the stages that data flow from BE database to Frontend.

  1. The Database (Normalized): Optimized for integrity. It uses a "Single Source of Truth" so that updating a user's name in one table reflects everywhere instantly.

  2. The API (Denormalized): Optimized for delivery. It flattens or nests data into JSON, so the frontend can get everything it needs in one request across the network.

  3. The Frontend (Re-Normalized): Optimized for performance. We take that JSON and break it back down into a local "mini-database." This ensures that when the user interacts with the app, updates are instant and the UI stays in sync without expensive re-renders or complex loops. For example:

    • Finding a message to edit it is O(n).

    • Deleting an item requires a filter() which loops through everything.

    • Syncing data (e.g., updating a user’s avatar in 20 different messages) requires a nested loop


Final Takeaway

Normalization isn't just for SQL experts. It's also a tool for frontend developers to keep their apps clean and fast. Stop tossing API responses into arrays and start building a structured mini-database for your data. Your users (and your CPU) will thank you.