Backend

Web Frontend Foundations from a Backend Collaboration Perspective

A backend-oriented review of frontend fundamentals, HTTP request flow, React state, routing, API calls, and team collaboration boundaries.

Web Frontend Foundations from a Backend Collaboration Perspective

When creating a backend API, if you do not know the screen, the response structure can easily become awkward. List screens require pagination and filters, detail screens need to decide how much relationship data to include, and create/edit screens need to return verification failures in units the user can understand. The reason to look at frontend fundamentals is not only to deeply implement screens, but also to understand how the API is consumed in actual user flows.

If you look at HTML, CSS, JavaScript, React, Router, Zustand, and Fetch/Axios separately, it looks like an introductory grammar. However, from a backend collaboration perspective, it all comes down to one problem: “What state does the client create, what request does it send, and what failure does it display to the user?”

HTML and CSS are the boundaries where API data is placed

HTML creates the semantic structure of the screen. Organize the meaning of elements such as titles, paragraphs, images, links, forms, and tables. Understanding this structure from a backend perspective makes it clearer what area of ​​the screen the API response is used for.

For example, a list screen may seem to require only a simple arrangement, but the actual UI requires a title, total number, page information, empty list message, action button, and loading status.

<section>
  <h1>Members</h1>
  <p>Total: 42</p>

  <table>
    <thead>
      <tr>
        <th>ID</th>
        <th>Name</th>
        <th>Status</th>
      </tr>
    </thead>
    <tbody>
      <!-- API data is rendered here -->
    </tbody>
  </table>
</section>

Errors for each field are important on form screens. If the backend simply returns Bad Request, the frontend has no way of knowing which input needs to be fixed.

<form>
  <label for="email">Email</label>
  <input id="email" name="email" type="email" />
  <p class="field-error">Email format is invalid.</p>
</form>

Therefore, it is better for collaboration to provide verification failure responses on a field-by-field basis.

{
  "code": "VALIDATION_FAILED",
  "message": "Request validation failed.",
  "fields": {
    "email": "Email format is invalid.",
    "password": "Password must be at least 12 characters."
  }
}

CSS is not just about visual expression. It reveals problems that occur when data enters the screen, such as long names, empty descriptions, broken images, mobile screens, dark mode, and error message length. For example, cards or tables can easily be broken if the backend does not limit the length of name.

.member-name {
  max-width: 240px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

You can abbreviate words on the front end, but if data requires a maximum length according to domain rules, API verification must also be included. The same goes for images. You need to decide whether there can be no image, whether to provide a replacement image, and how to create alt text.

{
  "id": 10,
  "name": "Project Alpha",
  "thumbnailUrl": null,
  "thumbnailAlt": "Project Alpha architecture thumbnail"
}

HTML attributes are both screen syntax and hints for data contracts. required, minlength, maxlength, and type="email" provide first-level verification at the browser level. However, this does not replace server verification. The front end helps users get feedback quickly, and the back end provides final validation of untrusted input.

<form method="post" action="/api/profile">
  <label for="email">Email</label>
  <input
    id="email"
    name="email"
    type="email"
    required
    maxlength="120"
    autocomplete="email"
  />

  <button type="submit">Save</button>
</form>

Even in this small form, there are many points to agree on with the backend. name="email" becomes the field name of the request body, maxlength is linked to the DB column length, and autocomplete affects browser operation and personal information entry experience. If it is a file upload, accept, multiple, and enctype must also match the API design.

frontend validation
  - immediate feedback
  - input format hint
  - disabled state and loading state

backend validation
  - trusted validation boundary
  - authorization check
  - persistence constraints
  - audit and error contract

CSS can easily be broken in operation if you write it based on only normal cases. You should check if the title becomes longer, if multilingual sentences are placed inside the button, if the image ratio is different, or if the table columns increase. This problem is not only a design issue, but is also connected to the API response format. Data that the screen cannot handle ultimately becomes unreadable to the user.

JavaScript async turns user events into server requests

JavaScript attaches actions to the screen. When learning DOM manipulation, APIs such as querySelector, addEventListener, and classList are used, and from a backend perspective, they are helpful in understanding when user actions turn into requests.

const form = document.querySelector("#member-form");

form.addEventListener("submit", async (event) => {
  event.preventDefault();

  const name = document.querySelector("#name").value;

  const response = await fetch("/api/members", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({ name })
  });

  if (!response.ok) {
    // render field or global error
  }
});

The important point here is event.preventDefault(). Submitting a basic form reloads the page. SPA prevents this and handles it as an asynchronous request. The backend then has to worry more about handling JSON responses, status codes, error types, and authentication expiration rather than redirects.

JavaScript asynchronous processing has evolved into callbacks, promises, and async/await. For front-end code that calls back-end APIs, async/await and try/catch/finally are the most readable.

async function loadMembers() {
  try {
    const response = await fetch("/api/members");

    if (!response.ok) {
      throw new Error("Failed to load members");
    }

    return await response.json();
  } catch (error) {
    Console.error(error);
    throw error;
  } finally {
    Console.log("request finished");
  }
}

Caution is also required when dealing with asynchrony in loops. forEach does not wait for await. For sequential processing, for...of is better, and for parallel processing, Promise.all or Promise.allSettled should be chosen intentionally.

for (const id of memberIds) {
  const member = await fetchMember(id);
  Console.log(member);
}

const results = await Promise.allSettled(
  memberIds.map((id) => fetchMember(id))
);

From the backend perspective, this difference appears in traffic patterns. Rate limit, pagination, batch endpoint, and cache strategies vary depending on whether the frontend sends 50 requests in parallel at a time or one page at a time.

Difference between Fetch and Axios leads to error contract

Both Fetch and Axios send HTTP requests, but they handle errors differently. Fetch does not reject 4xx/5xx promises unless there is a network error. So you need to check response.ok directly.

const response = await fetch("/api/members");

if (!response.ok) {
  const error = await response.json();
  throw new Error(error.message);
}

const data = await response.json();

Axios defaults to rejecting status codes outside the 2xx range.

try {
  const response = await axios.get("/api/members");
  return response.data;
} catch (error) {
  if (error.response?.status === 401) {
    // redirect to login or refresh token
  }
}

This difference is connected to the backend error design. By distinguishing between 400, 401, 403, 404, 409, 422, and 500, the front end can provide different messages and actions to the user.

400 Bad Request        malformed request
401 Unauthorized       missing or expired authentication
403 Forbidden          authenticated but not allowed
404 Not Found          resource does not exist
409 Conflict           state conflict, duplicate resource
422 Unprocessable      validation failed
500 Internal Error     unexpected server failure

Timeouts and request cancellations are also important. If a user changes their search terms quickly, you may need to cancel previous requests.

const controller = new AbortController();

fetch(`/api/search?q=${encodeURIComponent(query)}`, {
  signal: controller.signal
});

controller.abort();

The backend must be able to tolerate canceled requests, duplicate requests, and short intervals of retrieval requests. If necessary, rate limit, debounce, cache, and pagination must be designed together.

React state pressures API contracts

The core of React is components and state. Props are read-only data passed down from parent to child, and useState manages changing values ​​within the component. As the screen becomes more complex, you need to clearly see what state the API response is rendered in.

function MemberList() {
  const [members, setMembers] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function loadMembers() {
      setIsLoading(true);
      setError(null);

      try {
        const response = await fetch("/api/members");
        if (!response.ok) {
          throw new Error("Failed to load members");
        }
        setMembers(await response.json());
      } catch (error) {
        setError(error);
      } finally {
        setIsLoading(false);
      }
    }

    loadMembers();
  }, []);

  if (isLoading) return <Loading />;
  if (error) return <ErrorMessage error={error} />;
  if (members.length === 0) return <EmptyState />;

  return <MembersTable members={members} />;
}

Just looking at this code, there are at least three states on the screen.

loading
success
error

Statuses such as empty list, unauthorized, network failure, verification failure, and partial success are attached to this. If the backend API cannot distinguish between these states, the frontend will process all errors as a single message.

It is better for the list API to include page information rather than a simple array.

{
  "items": [],
  "page": 1,
  "size": 20,
  "total": 0,
  "hasNext": false
}

React's conditional rendering is the process of converting this response structure into UI. Although if, &&, and the ternary operator are syntax, they are actually tools that convert API state to screen state.

The form is the closest screen to API verification.

Form input is often treated as a controlled component. By connecting value and onChange to the state, the value entered by the user will be synchronized with the React state.

function MemberForm() {
  const [email, setEmail] = useState("");
  const [name, setName] = useState("");
  const [fieldErrors, setFieldErrors] = useState({});

  async function handleSubmit(event) {
    event.preventDefault();

    const response = await fetch("/api/members", {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({ email, name })
    });

    if (response.status === 422) {
      const error = await response.json();
      setFieldErrors(error.fields ?? {});
      return;
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={email} onChange={(event) => setEmail(event.target.value)} />
      {fieldErrors.email ? <p>{fieldErrors.email}</p> : null}

      <input value={name} onChange={(event) => setName(event.target.value)} />
      {fieldErrors.name ? <p>{fieldErrors.name}</p> : null}

      <button type="submit">Save</button>
    </form>
  );
}

Here, if the API does not provide errors for each field, it is difficult for the user to know which input is incorrect. Duplicate emails can be viewed as 409 Conflict, and input format errors can be viewed as 422. The important thing is that the frontend and backend interpret status codes using the same criteria.

Create, modify, and delete requests are also tied to UI state. After deletion, you must decide whether to remove the item from the list or retrieve it from the server. After modification, you must decide whether to update optimistically or update based on response data.

async function handleDelete(id) {
  const response = await fetch(`/api/members/${id}`, {
    method: "DELETE"
  });

  if (!response.ok) {
    throw new Error("Failed to delete member");
  }

  setMembers((members) => members.filter((member) => member.id !== id));
}

Looking at this flow, whether the DELETE API gives an empty response, returns a deleted resource, or returns a soft delete state becomes a collaboration point.

Router determines screen structure and data pre-loading

React Router's Data Mode treats routing as a data loading boundary rather than a simple screen transition. URL paths, layouts, nested routes, loaders, and redirects work together.

const router = createBrowserRouter([
  {
    element: <DefaultLayout />,
    children: [
      {
        path: "/members",
        element: <MembersPage />,
        loader: membersLoader
      },
      {
        path: "/members/:memberId",
        element: <MemberDetailPage />,
        loader: memberDetailLoader
      }
    ]
  }
]);

Dynamic segments resemble API resource paths.

/members/:memberId
/api/members/:memberId

You can check the authentication status in the router's loader and send unauthenticated users to the login page.

export async function membersLoader() {
  const session = await getSession();

  if (!session) {
    throw redirect("/login?redirectTo=/members");
  }

  return fetchMembers();
}

From the backend perspective, this structure is connected to 401/403 processing. If there is no authentication, 401 must be returned, and if authenticated but without authorization, 403 must be returned for the front-end router and screen to respond correctly.

When distributing an SPA, direct URL access should also be considered. When I refresh with /members/1 and the server tries to find the actual file, I get a 404. Hosting such as Vercel, Netlify, and Firebase require a rewrite setting that redirects all paths to index.html.

{
  "rewrites": [
    {
      "source": "/:path*",
      "destination": "/index.html"
    }
  ]
}

Frameworks like Next.js have different routing and server rendering models, but the URL creates a data loading boundary.

Zustand is a tool to narrow responsibility for client state

If the state is only used within a nearby component, useState is sufficient. However, when multiple screens and components share the same state, prop drilling, which continuously lowers props, occurs. Stores like Zustand reduce this problem.

import { create } from "zustand";

type SessionState = {
  userId: string | null;
  setUserId: (userId: string | null) => void;
};

export const useSessionStore = create<SessionState>((set) => ({
  userId: null,
  setUserId: (userId) => set({ userId })
}));

In a component, only the necessary values ​​are brought into the selector. Importing the entire store may increase unnecessary re-rendering.

const userId = useSessionStore((state) => state.userId);
const setUserId = useSessionStore((state) => state.setUserId);

When fetching multiple states at once, you can reduce re-rendering by using shallow comparisons. The persist middleware allows you to store state in localStorage, but you should not inadvertently store sensitive values ​​such as authentication tokens. The client state is close to a cache or UI state for convenience, and the final decision on authority must be made by the server.

import { persist } from "zustand/middleware";

export const usePreferencesStore = create(
  persist(
    (set) => ({
      theme: "system",
      setTheme: (theme) => set({ theme })
    }),
    { name: "preferences" }
  )
);

By understanding Zustand, the backend can better distinguish between “state that the server must own” and “state that the client can hold for a while.” The client state may be sufficient for whether the shopping cart, temporary filter, theme, and sidebar are opened, but the server must be the standard for payment status, permissions, inventory, and approval.

When router and store are used together, responsibilities are easily mixed. If the value that should be in the URL query parameter is only in the global store, the status disappears when refreshed or shared link. Conversely, if you include all modal open states that are used only inside the screen in the URL, routing becomes unnecessarily complicated.

URL state
  - page
  - keyword
  - selected tab
  - resource id

client UI state
  - modal open
  - temporary form draft
  - sidebar collapsed
  - optimistic loading state

server state
  - user permission
  - payment status
  - inventory
  - approval result

Taking a list screen as an example, it is better to leave page, size, keyword, and sort in the URL. That way, users can see the same results even if they share or refresh the link. However, whether a checked checkbox or a temporary filter panel is opened may be sufficient as client state.

function buildMembersUrl(params: {
  page: number;
  size: number;
  keyword?: string;
  sort?: "createdAt" | "name";
}) {
  const search = new URLSearchParams({
    page: String(params.page),
    size: String(params.size),
    sort: params.sort ?? "createdAt"
  });

  if (params.keyword) {
    search.set("keyword", params.keyword.trim());
  }

  return `/members?${search.toString()}`;
}

This function looks like a front-end utility, but is directly connected to the API design. The backend should document whether the starting value of page is 0 or 1, what the maximum value of size is, what fields can be sorted, and how it will handle empty search terms. Otherwise, different query rules will be created for each screen.

Web standards and accessibility also affect API design

If you look at HTML and CSS simply as technologies for decorating the screen, there are things you miss. Web standards, cross-browsing, and accessibility are linked to how users consume the same data in different environments. This perspective is also important from the backend perspective. Accessibility, internationalization, search, and automation possibilities vary depending on whether the value returned by the API is a string tailored only to the screen or a state value that machines can understand.

For example, if the order status is presented only in Korean sentences to be displayed directly on the screen, it is difficult for the front-end to consistently process colors, icons, filters, and accessibility labels.

{
  "statusText": "Preparing shipment"
}

Separating screen display text and system status values ​​makes it much easier to handle.

{
  "status": "PREPARING_SHIPMENT",
  "label": "Preparing shipment",
  "severity": "info"
}

In this structure, the screen performs conditional rendering based on status, and label can be changed to suit the user's language. Text, color contrast, status badges, and filter options for screen readers can also be created consistently based on the same status values.

Web accessibility is not the responsibility of the front end alone. The API must clearly provide the date, amount, status, and error code for the screen to construct meaningful HTML. Rather than giving the date as an arbitrary string, you can use the browser's locale formatting by setting the ISO format and timezone standard. Amounts should also contain a currency code and numeric value as well as a display string to be safe from sorting, filtering, and assistive reading technologies.

{
  "amount": 12000,
  "currency": "KRW",
  "createdAt": "2025-12-30T09:00:00+09:00"
}

Layout reveals exceptions in data

When learning CSS Flexbox or Grid, you will separately memorize properties such as justify-content, align-items, gap, and flex-wrap. However, in actual collaboration, “whether the API data breaks the screen” is more important. Users with long names, data with empty fields, cards without images, buttons that disappear depending on permissions, and lists that change height while loading all appear as layout problems.

.member-card {
  display: flex;
  align-items: center;
  gap: 12px;
  min-width: 0;
}

.member-card__name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

The CSS above may seem like a simple style, but it determines how the UI will survive when the API data becomes longer. The backend is also not unrelated to this problem. In the list API, you must decide which field to give the display name, auxiliary description, avatar URL, and status value to, so that the frontend handles exceptions reliably.

Card lists are particularly affected by data contracts.

{
  "items": [
    {
      "id": "usr_01",
      "displayName": "Kim Taeyoung",
      "email": "taeyoung@example.com",
      "avatarUrl": null,
      "role": "ADMIN",
      "lastActiveAt": "2025-12-30T10:15:00+09:00"
    }
  ],
  "page": 1,
  "size": 20,
  "totalCount": 132
}

These responses can create cards, tables, search results, and empty states all on the screen. Conversely, if you only combine the sentences visible on the screen, reusability decreases. Understanding the frontend layout makes it easier to determine how much atomic data the backend API should provide.

JavaScript functions and modules create the boundary of the API client

It is better to view JavaScript functions, classes, modules, and import/export as a way to divide boundaries rather than the grammar itself. When the API call code is scattered within the component, authentication header, error handling, timeout, base URL, and retry policy change for each screen. This may seem fine for a small project, but as features increase, it becomes difficult to track problems during operation.

type APIError = {
  code: string;
  message: string;
  fieldErrors?: Record<string, string>;
};

async function request<T>(path: string, init?: RequestInit): Promise<T> {
  const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}${path}`, {
    ...init,
    headers: {
      "Content-Type": "application/json",
      ...init?.headers
    }
  });

  if (!response.ok) {
    const error = (await response.json()) as APIError;
    throw error;
  }

  return response.json() as Promise<T>;
}

By having such a thin API client, the component can focus on “what state to show” rather than “how to send the request.”

export type Member = {
  id: string;
  name: string;
  email: string;
  status: "ACTIVE" | "INACTIVE";
};

export function getMembers() {
  return request<Member[]>("/members");
}

Function default values, destructuring assignments, and remaining parameters are also frequently used in actual API clients.

type ListMembersParams = {
  page?: number;
  size?: number;
  keyword?: string;
};

export function listMembers({
  page = 1,
  size = 20,
  keyword = ""
}: ListMembersParams = {}) {
  const search = new URLSearchParams({
    page: String(page),
    size: String(size),
    keyword
  });

  return request<Member[]>(`/members?${search.toString()}`);
}

This structure is also in contact with the backend. Knowing how the frontend combines page, size, and keyword allows you to design API query parameter default values, maximum size limits, search term trim, sorting criteria, and empty result responses more clearly.

Development environment affects collaboration speed

Vite is a great build tool to quickly start a React project. The development server is fast, and TypeScript templates can be easily created.

npm create vite@latest my-app
cd my-app
npm install
npm run dev

As the project grows into a collaborative object, setting up ESLint and Prettier also becomes important. If the code style changes every time, the essence of the review becomes unclear.

{
  "editor.formatOnSave": true,
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": "explicit"
  }
}

Mock API also speeds up collaboration. If you set the initial API form using a tool such as JSON Server, the front-end can create the screen state even before the back-end implementation is completed.

{
  "members": [
    {
      "id": 1,
      "email": "member@example.com",
      "name": "Kim",
      "status": "ACTIVE"
    }
  ]
}
json-server --watch db.json --port 5000

However, the Mock API may not sufficiently reflect the authentication, delay, failure, pagination, and validation of the actual server. So, at some point, you need to adapt to the OpenAPI specifications, contract test, and staging API.

Cleanup

Front-end fundamentals are not separate knowledge from back-end. HTML structures make room for API data, CSS exposes data lengths and exception conditions, and JavaScript asynchronous turns user events into server requests. React state converts API states such as loading, success, error, empty, and validation into screens. The Router creates URL and data loading boundaries, and the Zustand narrows responsibility to client state.

From a backend collaboration perspective, the important question is simple: What state does this screen have? What input errors can users fix themselves? How do authentication failures and lack of permissions look different? Will the list be viewed page by page? After deletion and modification, will the screen be updated based on the server response? When these questions can be answered, the API is not only true on paper, but is also naturally consumed within the actual screen flow.

Unauthorized copying, redistribution, and automated scraping are not permitted. Please cite the original URL when referencing this article.