진돌.dev
ReactResponsiveClean CodeJul 5, 2026 · 15 min read

How Should We Develop Responsive Web for PC/Mobile? (Honestly, I'm Unsure)

Jinseok Yeo

Introduction

We had to launch the service quickly. Pages were divided among us and developed concurrently, and while the requirement for a "responsive design that supports both PC and mobile with a single URL" was clear, there was a lack of convention on how to implement it.

Some used CSS media queries, some rendered both mobile and PC versions and hid one, some completely separated components into two versions, and some branched their work using JS hooks.

As we entered the maintenance phase, the same "responsive modification" required understanding the structure from scratch each time due to differing approaches across files. Reviews were fragmented, jumping between files, and with two designs but one codebase, every modification required checking if it broke the other version. The moments of wondering "Is this right?" became increasingly frequent during development.

How Our Team Implements Responsive Design

A comprehensive survey of the codebase revealed four distinct paths.

  1. Inline Media Queries. The most common choice. Mobile styles are set as default and overridden for desktop. There are commendable aspects — over 98% of files using this method reference central media query tokens and adhere well to mobile-first principles. There was discipline:
// SomeButton.css.ts
export const button = style({
  padding: '10px 20px',
  ...tokens.typography['16px'],
  '@media': {
    [tokens.mediaQuery.desktop]: {
      padding: '8px 18px',
      ...tokens.typography['14px'],
    },
  },
});
  1. display:none Dual Render. Both mobile and PC components are rendered side-by-side, and one is hidden with CSS. Both versions always exist in the DOM:
// ItemList.tsx
<div className={styles.itemList}>
  <ItemListMobile items={items} />   {/* `display:none` on PC */}
  <ItemListDesktop items={items} />  {/* `display:none` on mobile */}
</div>
// ItemList.css.ts — Hiding rules
export const mobileWrapper = style({
  display: 'flex', flexDirection: 'column', gap: 16,
  '@media': { [tokens.mediaQuery.desktop]: { display: 'none' } },
});
export const desktopWrapper = style({
  display: 'none',
  '@media': { [tokens.mediaQuery.desktop]: { display: 'block' } },
});

My Page-related sections are a dense area for this pattern (over 40 files using wrappers). From the recent orders section, which mounts the same data as two versions—a carousel (mobile) and a table (PC)—there are even screens where a title exists in two versions just for a single typography size difference (16px vs 18px):

// Section title on profile settings screen — two versions due to a single font size
<MobileView>
  <Box pt={12} pb={8}>
    <Typography as="p" variant="16px" weight="bold">{section.title}</Typography>
  </Box>
</MobileView>
<DesktopView>
  <Typography as="p" variant="18px" weight="bold">{section.title}</Typography>
</DesktopView>
  1. Complete Component Separation. For areas with entirely different structures, like headers, files were separated from the start, e.g., DesktopHeader/MobileHeader. Each file is a single layout without @media, so it can be read considering only one device:
// DesktopHeader.tsx — Single layout without @media
export function DesktopHeader({ leftSlot, centerSlot, rightSlot }: DesktopHeaderProps) {
  return (
    <header role="banner">
      <nav className={styles.container} aria-label="Desktop header navigation">
        <div className={styles.logoSlot}>{leftSlot ?? <Logo height={24} />}</div>
        <div className={styles.centerSlot}>{centerSlot}</div>
        <div className={styles.actionsSlot}>{rightSlot}</div>
      </nav>
    </header>
  );
}
// MobileHeader.tsx is an entirely different file with a hamburger button + search icon structure

However, the branching point for choosing between the two ultimately followed Pattern 2 (dual render) — while file separation was clean, there was no standard for "what to use as the branching point":

// Branching point — Renders both versions and toggles with CSS (combination of Pattern 3 and Pattern 2)
<MobileView><MobileHeader /></MobileView>
<DesktopView><DesktopHeader /></DesktopView>

There are also cases where the branching point selection differs despite using the same complete separation. All 8 sections of the membership page are Desktop/Mobile function pairs, but the selection is made by JS, not CSS:

// Membership tier banner — approx. 160 lines (Desktop function + Mobile function)
function TierBannerDesktop() {
  const { data } = useSuspenseQuery(membershipQueries.summary());
  // …tier calculation, progress, performance modal handler…
  return <Box className={styles.desktopBanner}>{/* Horizontal banner + right card */}</Box>;
}
function TierBannerMobile() {
  const { data } = useSuspenseQuery(membershipQueries.summary());
  // …exactly the same logic again…
  return <VStack px={16}>{/* Vertical stack + speech bubble card */}</VStack>;
}
export function TierBanner() {
  const platform = usePlatform(); // SSR initial value fixed to 'PC' + matchMedia determination
  return platform === 'PC' ? <TierBannerDesktop /> : <TierBannerMobile />;
}

The two functions have identical data hooks and calculation logic, differing only in JSX (approx. 30 lines of duplication). Separation was done, but shared logic wasn't extracted, leading to duplication, and since the branching is done by JS, the SSR cost remains the same.

  1. JS Hook Prop Branching. Within a single component, values, text, and spacing are all branched using ternary operators:
// OrderInfoRow.tsx
const isMobile = useIsMobile();
const isDesktop = useIsDesktop();
return (
  <div>
    {isDesktop && <span className={styles.divider}>|</span>}
    <span>{isMobile && 'No. '}{orderNo}</span>
    ...
    <Space size={isMobile ? 16 : 24} />
    <Space size={isMobile ? 32 : 56} />
  </div>
);

All four methods "work." The problem is that they coexist within a single codebase, and are even mixed within a single screen.

The four patterns not only vary by file, but are also mixed within a single page — My Page is a dense area for Pattern 2, while Membership divides with Pattern 3 and selects with Pattern 4. Real-world examples are included with each pattern description.

Inconveniences Experienced During Development

The above describes the current situation, and below are the inconveniences I've felt.

1. CSS Complexity — One File Responsible for Two Layouts

The real problem isn't value differences like font size. It's when a single file is simultaneously responsible for two UIs with fundamentally different layout structures. The review detail popup style file is over 500 lines with 26 @media blocks — it's a vertical stack on mobile and a two-column (left/right) layout on PC, so almost every selector has an inverted branch:

// ReviewPopup.css.ts (out of ~500 lines) — Vertical stack ↔ Two-column layout switch
export const container = style({
  display: 'flex',
  flexDirection: 'column',   // Mobile: Vertical stack
  height: '100%',
  '@media': {
    [tokens.mediaQuery.desktop]: {
      flexDirection: 'row',  // PC: Switches to horizontal layout
      height: '640px',
      gap: '32px',
    },
  },
});
export const contentPane = style({
  width: '100%',             // Mobile: Full width
  paddingTop: '24px',
  '@media': {
    [tokens.mediaQuery.desktop]: {
      width: '50%',          // PC: Half width
      padding: 0,
    },
  },
});

The common overlay takes it a step further, with a single file responsible for two distinct components: a mobile bottom sheet and a PC central modal:

// OverlayLayer.css.ts — Bottom sheet ↔ Modal switch
const dimmed = style({
  position: 'fixed',
  inset: 0,
  display: 'flex',
  justifyContent: 'center',
  alignItems: 'end',          // Mobile: Aligns to bottom (bottom sheet)
  '@media': {
    [tokens.mediaQuery.desktop]: {
      alignItems: 'center',   // PC: Aligns to center (modal)
    },
  },
});
const panel = style({
  width: '100%',
  borderRadius: '16px 16px 0 0',      // Mobile: Rounded only at the top
  transform: 'translate(0, 100%)',    // Mobile: Slides up from off-screen bottom
  '@media': {
    [tokens.mediaQuery.desktop]: {
      borderRadius: 0,                // PC: No rounded corners
      width: '480px',
    },
  },
});

The CTA button on the payment page is a fixed bottom bar on mobile and inline within the main content on PC — position: fixed ↔ static is flipped within a single style block. On top of this, 14px→16px type typography pair repetitions accumulate across dozens of files. These files are not truly "one responsive file," but rather two distinct UIs compressed into a single file.

2. Constant Back-and-Forth Between tsx and css.ts During Review and Modification

To understand a single layout change during code review, one must read the .tsx file, then go to the .css.ts file to find the class, and then return to check the conditions. What's worse is when a single component uses both CSS branching and JS branching simultaneously — Product details, for example, change flex-order with @media in .css.ts while branching margins with isMobile ? 16 : 0 in .tsx. Understanding one layout requires navigating two files and two mechanisms.

3. Designs Come in Two Versions, But Code is One

  • Both design mockups and product requests come in two versions: mobile/desktop. However, the code is merged into a single file.
  • QA and testing also always involve two versions — every time one is fixed, we must check if the other is broken.
  • This is critical when working with AI: implementing "mobile design" and "PC design" modifies the same file, making parallel work impossible. If files were separated, two agents (or two people) could work simultaneously.

Why Does Development Feel So Inconvenient and Difficult?

I wanted to ascertain if the inconvenience was merely a matter of familiarity, or a structural issue explainable by principles.

Toss frontend-fundamentals defines good code by four criteria: readability, predictability, cohesion, and coupling. Taking examples directly from four representative documents, I applied our responsive code to the same format.

1. Separating Code That Doesn't Run Together — Readability

The original text gives an example of a button that behaves differently based on permissions:

// Toss Original — Before
function SubmitButton() {
  const isViewer = useRole() === "viewer";
  useEffect(() => {
    if (isViewer) return;
    showButtonAnimation();
  }, [isViewer]);
  return isViewer ? (
    <TextButton disabled>Submit</TextButton>
  ) : (
    <Button type="submit">Submit</Button>
  );
}

Code that doesn't execute simultaneously appears interleaved, making it harder to understand.

Now, if we replace isViewer with isMobile, we can see that Pattern 4 (JS hook prop branching) discussed earlier is exactly like the 'Before' example in the original text. Looking at our code directly:

// Our Code — Before: PC code that never runs on mobile is interleaved in the same JSX
function OrderInfoRow({ orderNo }: Props) {
  const isMobile = useIsMobile();
  const isDesktop = useIsMobile();
  return (
    <div>
      {isDesktop && <span className={styles.divider}>|</span>}
      <span>{isMobile && 'No. '}{orderNo}</span>
      <Space size={isMobile ? 16 : 24} />
      <Space size={isMobile ? 32 : 56} />
    </div>
  );
}

There are over 370 such interleavings in the codebase.

The improvement in the original text is to completely separate components by permission:

// Toss Original — After
function SubmitButton() {
  const isViewer = useRole() === "viewer";
  return isViewer ? <ViewerSubmitButton /> : <AdminSubmitButton />;
}
function ViewerSubmitButton() {
  return <TextButton disabled>Submit</TextButton>;
}
function AdminSubmitButton() {
  useEffect(() => {
    showButtonAnimation();
  }, []);
  return <Button type="submit">Submit</Button>;
}

Improving our code in the same way, each version becomes straightforward code without branching:

// Our Code — After: Branching in one place, each version has 0 conditions
function OrderInfoRow(props: Props) {
  const isMobile = useIsMobile();
  return isMobile ? <OrderInfoRowMobile {...props} /> : <OrderInfoRowDesktop {...props} />;
}
function OrderInfoRowMobile({ orderNo }: Props) {
  return (
    <div>
      <span>{'No. '}{orderNo}</span>
      <Space size={16} />
    </div>
  );
}
function OrderInfoRowDesktop({ orderNo }: Props) {
  return (
    <div>
      <span className={styles.divider}>|</span>
      <span>{orderNo}</span>
      <Space size={24} />
    </div>
  );
}

2. Reducing Context Switching — Readability

The original text shows code where understanding why a button is disabled requires three jumps:

// Toss Original — Before: "Why is the Invite button disabled?" — three jumps for one answer
<Button disabled={!policy.canInvite}>Invite</Button>      // ① Where is policy.canInvite from?

const policy = getPolicyByRole(user.role);                 // ② What does getPolicyByRole do?

function getPolicyByRole(role) {
  return { canInvite: POLICY_SET[role].includes("invite") }; // ③ The answer is only found at POLICY_SET
}
const POLICY_SET = { admin: ["invite", "view"], viewer: ["view"] };

Looking at a real-world example from our code, it's even more dramatic. The path to understanding when a single share button is visible in the event header component:

// EventHeader.tsx — ① JS calculates the floating button's 'state' using isMobile
const getFloatingPosition = (isMobile, isAtTop, isScrollingDown) => {
  if (!isMobile) return 'hidden';        // ② JS branch: Hidden on PC… but
  if (!isAtTop && isScrollingDown) return 'bottom';
  if (isAtTop && !isScrollingDown) return 'stackOne';
  return 'stackTwo';
};
// …
<div className={styles.staticShareArea}>{shareButton}</div>              {/* ③ Check class */}
<div className={styles.floatingShareArea({ position })}>{shareButton}</div>
// EventHeader.css.ts — ④ Visibility of the same buttons is 'again' controlled by @media
export const staticShareArea = style({
  display: 'none',                       // Mobile: Static button hidden
  '@media': { [tokens.mediaQuery.desktop]: { display: 'flex' } },
});
export const floatingShareArea = recipe({
  base: {
    position: 'fixed',
    '@media': { [tokens.mediaQuery.desktop]: { display: 'none' } },  // PC: Floating element entirely hidden
  },
  variants: { position: { bottom: { bottom: 32 }, stackOne: { bottom: 64 }, stackTwo: { bottom: 112 }, hidden: { opacity: 0 } } },
});

The visibility of the same share button is dually controlled by two mechanisms: JS (isMobile → position calculation) and CSS (@media display:none). To answer 'Why isn't this button visible on PC?', despite being a simple question, requires jumping back and forth between multiple code sections.

3. Allowing Duplicate Code — Coupling

The original text shows a commonized hook and warns against it, citing its repetition across multiple pages:

// Toss Original — Commonized maintenance bottom sheet hook
export const useOpenMaintenanceBottomSheet = () => {
  const maintenanceBottomSheet = useMaintenanceBottomSheet();
  const logger = useLogger();
  return async (maintainingInfo: TelecomMaintenanceInfo) => {
    logger.log("Maintenance bottom sheet opened");
    const result = await maintenanceBottomSheet.open(maintainingInfo);
    if (result) {
      logger.log("Maintenance bottom sheet 'Notify Me' clicked");
    }
    closeView();
  };
};

If there's a possibility for behavior to differ across pages, allowing duplicate code without commonization is a better choice.

We violated this principle in the opposite direction. We commonized two layouts into one file, reasoning that 'PC and mobile are the same screen', but actual requests come in separately.

// Our Code — Before: OrderSkeleton.tsx, 37 ternary operations for isMobile
export const OrderSkeleton = () => {
  const isMobile = useIsMobile();
  return (
    <>
      <Skeleton width="100%" height={isMobile ? 46 : 62} borderRadius={isMobile ? 0 : 4} />
      <Space size={isMobile ? 0 : 40} />
      <Skeleton width={isMobile ? 144 : 130} height={isMobile ? 32 : 24} />
      {!isMobile && <div className={styles.line} />}
      {isMobile ? <div className={styles.divider} /> : <Space size={48} />}
      <Skeleton width={160} height={isMobile ? 18 : 22} />
      <Space size={isMobile ? 2 : 4} />
      {/* …such ternary operations appear 37 times throughout the file */}
    </>
  );
};

This is the result of trying to contain two entirely different skeleton layouts in one function "without duplication." If duplication is allowed, each file becomes straightforward code without conditions:

// Our Code — After: Allowing layout duplication and creating two versions
export const OrderSkeleton = () => (
  <>
    <MobileView><OrderSkeletonMobile /></MobileView>
    <DesktopView><OrderSkeletonDesktop /></DesktopView>
  </>
);
function OrderSkeletonMobile() {
  return (
    <>
      <Skeleton width="100%" height={46} borderRadius={0} />
      <Skeleton width={144} height={32} />
      <div className={styles.divider} />
      {/* 0 conditional branches — reads straight from top to bottom */}
    </>
  );
}
// OrderSkeletonDesktop is identical — 37 ternary operations disappear, leaving two simple lists

The 500-line, 26-@media file seen earlier in "Inconveniences Experienced During Development" tells the same story. It's a structure where modifying a mobile design can break the PC version, a state where, as the original text states, "every time common code is modified, dependent code must be individually tested."

4. Exposing Hidden Logic — Predictability

The original text uses logging secretly embedded within a balance inquiry function as an example:

// Toss Original — Before
async function fetchBalance(): Promise<number> {
  const balance = await http.get<number>("...");
  logging.log("balance_fetched");   // Hidden behavior not predictable from name or return type
  return balance;
}

Only logic predictable from the function's name, parameters, and return type should remain in the implementation.

In our code, this 'smell' manifests as device-specific logic hidden within components. It's not discernible from the name and signature alone, but it hides or disappears itself internally:

// Our Code — Before ①: A section that seems like it would always render based on its name
export const RecentOrdersSection = () => {
  const isMobile = useIsMobile();
  const { data, isLoading } = useQuery(ordersQuery);
  const orders = data?.orders ?? [];
  // Mobile + no orders → section disappears entirely. The caller has no way of knowing.
  if (isLoading || (isMobile && !orders.length)) return null;
  return <section>…</section>;
};
// Our Code — Before ②: Always present in JSX, but CSS secretly hides it on mobile
// SectionTitle.css.ts
export const title = style({
  display: 'none', // Default (mobile): Hidden — someone reading only tsx wouldn't know
  '@media': { [tokens.mediaQuery.desktop]: { display: 'block' } },
});

To the question 'Why isn't this title visible on mobile?', tsx provides no answer — the hidden logic is in the CSS. The improvement is the same as in the original text. The hiding is revealed in the name and call site, not inside the implementation:

// Our Code — After: Condition moved to call site, device-specific nature indicated by wrapper/name
{(!isMobile || orders.length > 0) && <RecentOrdersSection orders={orders} />}

<DesktopView>
  <SectionTitle>Order Details</SectionTitle>  {/* Instead of CSS's secret display:none */}
</DesktopView>

Summary

I believe clean code remains crucial, especially as humans still need to review AI-generated code. It will serve as a very important benchmark for us to establish a unified direction.

Criterion Principle Our Symptoms
Readability Separate code that doesn't run together isMobile ? A : B interleaving in ~370 places — code for two devices mixed in one function
Readability Reduce context switching tsx ↔ css.ts ↔ tokens, cross-file navigation is the basic reading path
Coupling Allow duplicate code Combining two-version layouts (from design) into one file → ~500 lines / 26 @media blocks
Predictability Expose hidden logic Component hides itself internally based on device conditions

How Do Other Major E-commerce Sites Handle This?

Above, we examined issues in our code using clean code as a benchmark, and to gain insights into how others implement it, I asked fable to research major e-commerce sites.

1. Product List Measurements

Site Stack Implementation Decisive Evidence
29CM React + Tailwind 🟢 Media Query Responsive (Single Class) ⭐ grid grid-cols-3 desktop:grid-cols-5 — Same DOM, same class, 3 columns ↔ 5 columns
Ohouse Home React SSR + Emotion 🟢 CSS Media Query + Local Dual Render 171 media rules (768·1024·1256px + hover:hover 30 times). Only stats-mobile/stats-pc pairs are toggled
Ohouse Store Next.js + Emotion 🟠 JS (matchMedia) Branching Ranking grid: no corresponding rule in 375px iframe, 0 @media rules but viewport responsive = renders different tree
Kurly Next.js + Emotion (effectively CSR) 🔴 User Agent Adaptive — PC Fixed Width Both 375/1280 iframes have same 3 columns, 249px cards. Main 1050px fixed. "Single URL" is actually user agent branching
Musinsa Next.js + styled-components 🔴 User Agent Adaptive (Fluid) Fluid expansion of single mobile layout (min 744/max 1440). Consistent with PC web shutdown in 2024

2. Home Screen Measurements — Screens with Significantly Divergent Structures

Site Branching Mechanism Page Layout Header·GNB Bottom Tab Bar / Partial UI SSR Status
29CM 🟢 Viewport + CSS Toggle Magazine-style 3 columns (PC) ↔ Single hero (MO) Both PC GNB and mobile tab bar exist in DOM — symmetric toggle with hidden/desktop:flex Bottom tab bar is desktop:hidden, app webview is also hidden with webview:hidden Both versions server-rendered
Ohouse Home 🟢 CSS Media Query (Single DOM) Single DOM rearrangement — 38 main sections identical Same header tree rearranged across two widths Horizontal scroll section mobile 4 vs PC 1 — carouselization also with CSS SSR 322KB, device marker 0
Kurly 🔴 User Agent Adaptive PC (1080px+) renders cropped even in 387px viewport Only one PC version exists Not applicable SSR shell 15KB (effectively CSR)
Musinsa 🔴 User Agent Adaptive (Fluid) DOM virtually identical (2,876 vs 2,880 nodes), only width is fluid (744~1440) GNB identical — from tracking metadata pc_gnb 5 horizontal scroll carousels identical Device marker exists in SSR

Research Findings

To summarize the research findings in one sentence: Everyone is doing it differently. From pure media query responsive design (Coupang, 29CM) to JS branching (Ohouse Store), user agent adaptive (Kurly, Musinsa), and domain separation (Olive Young Korea), all are actively in use.

The reason everyone made different choices is likely due to the unique trade-offs associated with each approach, as listed below.

Method Pros Cons Examples of Adoption
① CSS Media Query (Single DOM) SSR-safe (server doesn't need to know device) · zero-runtime · immediate resize response Limitations if DOM structure differs significantly · complexity explosion if branching accumulates per selector Coupang · 29CM · Ohouse Home · Our product list
② display:none Dual Render Branches UIs with different structures using only CSS · simple implementation Dual DOM/bundle transmission · SEO duplicate content · significant waste if more than one screen's worth (accessibility is safe with display:none) 29CM GNB · Ohouse local fragments · Our header/order list
③ JS Branching (matchMedia hook) Renders only one DOM version · clearly expressed with logic SSR initial value issue → hydration mismatch or 2-pass render (slow) · flicker/CLS before determination Ohouse Store · Our useIsMobile 374 times
④ Server User Agent Detection Accurate from first render · no opposite code transmission (bundle -38.7% measured case) · safe hydration No viewport resize response · tablet/new user agent detection risk Market Kurly · Musinsa
⑤ Component/Route Separation File = 1:1 mockup · parallel work possible · each file is a single layout without @media Branching point design needed (must combine with ④ for SSR) · risk of dual modification for common logic/copy Our DesktopHeader/MobileHeader · velog route separation measurement

Essential Considerations for Responsive Implementation

To approach development with a specific method, I felt it was necessary to first understand the problems that would inevitably arise, regardless of the chosen approach, to make an informed decision. Therefore, I investigated at least these points first.

1. Bundle Size

Media query and dual-render methods require mobile users to download all PC code (and vice versa).

Here's an important distinction. Server user agent "detection" alone does not reduce bundle size. Our app already uses user agents (for app webview detection, bot detection to choose SSR streaming strategy), but none of these are linked to the bundle. Since bundle composition is determined by the static import graph at build time, even if the server knows the device, the bundle size remains the same if "which JS to send" isn't branched. Detection combined with serving separation (route separation or dynamic import) is required to reduce it.

2. SEO

  • Google uses mobile-first indexing — the mobile DOM must be the complete version of the content.
  • display:none dual rendering creates duplicate content. Crawlers read hidden content too, but if the management of the two versions diverges, it becomes ambiguous which is the canonical version.
  • User agent detection provides different HTML to Googlebot user agents (mobile/desktop versions), so caching management is needed to avoid being mistaken for cloaking.

3. Hydration Mismatch

This is inevitably encountered when using JS branching (③) in SSR. Since the server doesn't know the device, it fixes the initial value (we use desktop), and if it differs from the client's determination, React throws a mismatch. A 2-pass render (isHydrated gate) that safely bypasses this, instead incurs flicker and CLS. Our footer is precisely paying this cost:

// Footer app download section — appears only after hydration → late pop-in, CLS
{isHydrated && platform !== 'APP' && (
  <>
    <div className={styles.appBannerDesktop}>…QR code…</div>
    <div className={styles.appBannerMobile}>…store badge…</div>
  </>
)}

Future Plans

Developing two versions (PC/mobile) in the same repository, rather than just simple responsive development, was a first for me, and I had many concerns since I joined. While it deviates from my usual clean code practices, I lacked the experience to know the correct answer, making it difficult to assert a strong opinion.

Therefore, in this post, I analyzed the clean code perspective and how others are doing it to establish a benchmark.

Now that we've launched and our team has a bit more breathing room, I'm actively proposing this as an agenda item to my teammates, hoping we can resolve it. Once a conclusion is reached, I'll provide another update..!

Conclusion — Team Convention Confirmed (Updated 2026-07-10)

After Slack discussions, the direction was decided today. To put it simply, it's a three-stage convention based on CSS-driven responsive design, applying clean code principles as much as possible.

Basis for Decision

  • JS branching is risky. As seen in the "Hydration Mismatch" section, JS branching in SSR causes hydration mismatches or, to avoid them, forces a 2-pass render (flicker, CLS). The fundamental limitation that the server doesn't know the device doesn't disappear, no matter how well hooks are designed. Therefore, JS branching using useIsMobile-like approaches is excluded as a primary method.
  • We concluded that there is no perfect method for providing responsive design. Measurements showed that even major e-commerce sites use entirely different approaches, and each method incurs different costs in terms of bundle size, SEO, hydration, and resize responsiveness. It wasn't a matter of choosing "the one correct answer," but rather selecting the approach with the lowest cost for each specific case.
  • Therefore, we are moving towards maintaining responsive design (CSS) while applying clean code principles as much as possible. While keeping SSR-safe and zero-runtime CSS branching as the default, we divided cases into three stages to prevent the readability, predictability, cohesion, and coupling issues identified earlier.

Three-Stage Convention

1) Value & Simple Layout Changes (Most Common) → Responsive Prop Handling

Without opening CSS files, this is primarily resolved by passing objects to component props.

  • Key values are unified to { mo, pc }, and configured to be automatically compiled into a min-width: 1200px media query internally.
  • Covers not only font size and spacing (gap), but also simple Flex alignment direction or justification changes.
<Stack direction={{ mo: 'column', pc: 'row' }} gap={{ mo: 8, pc: 16 }} />

2) Layout Structures Unresolvable with Props → CSS Media Queries (Maintaining a Single Tree)

Applied when content and markup structure are the same, but the layout cannot be implemented with responsive props alone.

  • This applies when the DOM's arrangement order itself changes, or when too many layout props are attached, reducing readability.
  • To prevent markup sync issues (drift), component trees are not separated, and control is achieved by writing @media queries in styles like .css.ts within a single component.

3) When Structure or Interaction is Distinct → Component Separation + <Responsive> Switching

Used when mobile and PC markup structures are entirely different, or when there are large-scale functional differences (scroll interactions, swipes, etc.) that make it difficult to combine trees. (e.g., My Page, headers, hero banners)

  • Implemented using CSS toggle (display: none) — SSR-safe as it's not JS branching.
  • Business logic is separated into hooks to eliminate duplication between the two versions.
  • Naming is standardized by appending Mobile / Desktop suffixes.
<Responsive mobile={<OrderListMobile />} desktop={<OrderListDesktop />} />

By writing in the form of <Responsive mobile={...} desktop={...} />, the aim is to allow the branching structure for PC and mobile to be grasped intuitively and declaratively at a glance.

# React# Responsive# Clean Code
Jinseok Yeo

A frontend developer in commerce who improves both the product and how the team builds it.