[Feat] update breakpoint + add useBreakpoint - #275
Conversation
Summary by CodeRabbit릴리즈 노트
Walkthrough미디어 쿼리 기반 반응형 디자인을 지원하기 위해 Changes반응형 브레이크포인트 관리
Sequence DiagramsequenceDiagram
participant Component
participant useBreakpoint
participant getBreakpoint
participant MediaQueryList
participant useSyncExternalStore
Component->>useBreakpoint: 훅 호출
useBreakpoint->>getBreakpoint: 현재 뷰포트 너비 확인
getBreakpoint->>MediaQueryList: window.matchMedia로 각 범위 검사
MediaQueryList-->>getBreakpoint: 일치하는 breakpoint 반환
useBreakpoint->>useSyncExternalStore: subscribe 함수 등록
MediaQueryList->>useSyncExternalStore: change 이벤트 발생 시 콜백
useSyncExternalStore-->>Component: breakpoint와 파생 상태 반환
Component->>Component: 렌더링
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related issues
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint install failed: one or more packages not found in the registry. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces a new useBreakpoint hook using useSyncExternalStore to track responsive layout changes, and refactors the media query configuration to export explicit breakpoint values. The reviewer suggested optimizing the hook by caching window.matchMedia instances to prevent redundant object creation and simplifying the subscription logic to only listen to boundary changes.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| function getBreakpoint(): Breakpoint { | ||
| if (window.matchMedia(`(min-width: ${breakpoints.desktopLarge}px)`).matches) | ||
| return 'desktopLarge'; | ||
| if (window.matchMedia(`(min-width: ${breakpoints.desktop}px)`).matches) | ||
| return 'desktop'; | ||
| if (window.matchMedia(`(min-width: ${breakpoints.tablet}px)`).matches) | ||
| return 'tablet'; | ||
| return 'mobile'; | ||
| } | ||
|
|
||
| function subscribe(callback: () => void) { | ||
| const mqls = [ | ||
| window.matchMedia(`(max-width: ${breakpoints.tablet - 1}px)`), | ||
| window.matchMedia( | ||
| `(min-width: ${breakpoints.tablet}px) and (max-width: ${breakpoints.desktop - 1}px)`, | ||
| ), | ||
| window.matchMedia( | ||
| `(min-width: ${breakpoints.desktop}px) and (max-width: ${breakpoints.desktopLarge - 1}px)`, | ||
| ), | ||
| window.matchMedia(`(min-width: ${breakpoints.desktopLarge}px)`), | ||
| ]; | ||
| mqls.forEach((m) => m.addEventListener('change', callback)); | ||
| return () => mqls.forEach((m) => m.removeEventListener('change', callback)); | ||
| } |
There was a problem hiding this comment.
💡 성능 개선 및 코드 단순화 제안
현재 구현된 useBreakpoint 훅의 성능 최적화와 코드 안정성을 위해 아래 두 가지 개선 사항을 제안합니다.
-
window.matchMedia호출 최소화 및 캐싱 (성능 개선)getBreakpoint함수는 스냅샷을 확인할 때마다(컴포넌트 렌더링 및 상태 변경 시 자주 호출됨)window.matchMedia를 매번 새로 생성하고 문자열을 파싱합니다.window.matchMedia가 반환하는MediaQueryList객체는 실시간(live) 객체이므로, 한 번만 생성해 두고.matches값만 조회하는 방식으로 성능을 개선할 수 있습니다.- 또한, SSR 환경이나 테스트 환경(Jest 등)에서
window가 정의되지 않았을 때 발생할 수 있는ReferenceError를 방지하기 위해 안전한 방어 코드가 필요합니다.
-
미디어 쿼리 리스너 단순화 (복잡도 감소)
subscribe함수에서는 4개의 범위 기반 미디어 쿼리(max-width및and조건 포함)를 등록하고 있습니다.- 실제 브라우저 창 크기가 변할 때 breakpoint가 바뀌는 시점은 오직 **경계값(768px, 1024px, 1260px)**을 넘어설 때뿐입니다.
- 따라서
getBreakpoint에서 사용하는 동일한 3개의min-width쿼리만 구독(subscribe)해도 완전히 동일하게 동작하며, 불필요한 미디어 쿼리 객체 생성을 줄이고 코드를 단순하게 만들 수 있습니다.
const getMqls = () => {
if (typeof window === 'undefined') return [];
return [
{ key: 'desktopLarge' as const, mql: window.matchMedia(`(min-width: ${breakpoints.desktopLarge}px)`) },
{ key: 'desktop' as const, mql: window.matchMedia(`(min-width: ${breakpoints.desktop}px)`) },
{ key: 'tablet' as const, mql: window.matchMedia(`(min-width: ${breakpoints.tablet}px)`) },
];
};
let cachedMqls: ReturnType<typeof getMqls> | null = null;
function getCachedMqls() {
if (!cachedMqls) {
cachedMqls = getMqls();
}
return cachedMqls;
}
function getBreakpoint(): Breakpoint {
const mqls = getCachedMqls();
for (const { key, mql } of mqls) {
if (mql.matches) return key;
}
return 'mobile';
}
function subscribe(callback: () => void) {
const mqls = getCachedMqls();
mqls.forEach(({ mql }) => mql.addEventListener('change', callback));
return () => mqls.forEach(({ mql }) => mql.removeEventListener('change', callback));
}There was a problem hiding this comment.
🧹 Nitpick comments (2)
src/hooks/useBreakpoint.ts (2)
15-27: ⚡ Quick win경계값만 구독하면 중복 알림을 줄일 수 있습니다.
Line 17-24처럼 구간별
MediaQueryList를 모두 구독하면 1024px 같은 경계 통과 시 이전 구간과 새 구간이 함께 바뀌어서callback이 두 번 호출됩니다.getBreakpoint()가 최종 상태를 계산하고 있으니 768/1024/1260 경계 세 개만 구독해도 동작은 같고 알림은 한 번으로 줄일 수 있습니다.변경 예시
function subscribe(callback: () => void) { const mqls = [ - window.matchMedia(`(max-width: ${breakpoints.tablet - 1}px)`), - window.matchMedia( - `(min-width: ${breakpoints.tablet}px) and (max-width: ${breakpoints.desktop - 1}px)`, - ), - window.matchMedia( - `(min-width: ${breakpoints.desktop}px) and (max-width: ${breakpoints.desktopLarge - 1}px)`, - ), - window.matchMedia(`(min-width: ${breakpoints.desktopLarge}px)`), + window.matchMedia(`(min-width: ${breakpoints.tablet}px)`), + window.matchMedia(`(min-width: ${breakpoints.desktop}px)`), + window.matchMedia(`(min-width: ${breakpoints.desktopLarge}px)`), ]; mqls.forEach((m) => m.addEventListener('change', callback)); return () => mqls.forEach((m) => m.removeEventListener('change', callback)); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/hooks/useBreakpoint.ts` around lines 15 - 27, The current subscribe function adds listeners for every range which causes duplicate callbacks at boundary transitions; instead subscribe only to the three breakpoint boundaries used by getBreakpoint(): listen to MediaQueryList for breakpoints.tablet, breakpoints.desktop and breakpoints.desktopLarge (e.g. matchMedia using those threshold queries) in the subscribe function, keep the addEventListener('change', callback) and return cleanup that removes those three listeners, so getBreakpoint still computes the final state but callback fires only once per boundary crossing.
30-35: ⚡ Quick winSSR 기본값을 고정
desktop으로 두면 모바일 첫 페인트가 틀릴 수 있습니다.Line 34의 서버 스냅샷이 항상
desktop이라서, 이 훅으로 마크업을 분기하는 컴포넌트는 모바일 SSR에서도 먼저 데스크톱 상태로 그려진 뒤 hydration 후 다시 바뀝니다. 기본값을 인자로 받게 열어 두거나 상위에서 서버 추정값을 주입할 수 있게 해두는 편이 안전합니다.변경 예시
-export function useBreakpoint() { +export function useBreakpoint(defaultBreakpoint: Breakpoint = 'desktop') { const bp = useSyncExternalStore( subscribe, getBreakpoint, - () => 'desktop' as Breakpoint, + () => defaultBreakpoint, );🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/hooks/useBreakpoint.ts` around lines 30 - 35, The server snapshot for useSyncExternalStore in useBreakpoint is hardcoded to 'desktop', causing mobile SSR to render wrong initial markup; change useBreakpoint to accept an optional serverSnapshot/defaultBreakpoint parameter (or an injected server-estimated breakpoint) and pass that into the third argument of useSyncExternalStore instead of the fixed 'desktop', referencing the existing useBreakpoint function and its use of subscribe and getBreakpoint so callers or the server can supply an appropriate initial breakpoint.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/hooks/useBreakpoint.ts`:
- Around line 15-27: The current subscribe function adds listeners for every
range which causes duplicate callbacks at boundary transitions; instead
subscribe only to the three breakpoint boundaries used by getBreakpoint():
listen to MediaQueryList for breakpoints.tablet, breakpoints.desktop and
breakpoints.desktopLarge (e.g. matchMedia using those threshold queries) in the
subscribe function, keep the addEventListener('change', callback) and return
cleanup that removes those three listeners, so getBreakpoint still computes the
final state but callback fires only once per boundary crossing.
- Around line 30-35: The server snapshot for useSyncExternalStore in
useBreakpoint is hardcoded to 'desktop', causing mobile SSR to render wrong
initial markup; change useBreakpoint to accept an optional
serverSnapshot/defaultBreakpoint parameter (or an injected server-estimated
breakpoint) and pass that into the third argument of useSyncExternalStore
instead of the fixed 'desktop', referencing the existing useBreakpoint function
and its use of subscribe and getBreakpoint so callers or the server can supply
an appropriate initial breakpoint.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 989536a4-a680-4bc3-9538-666783dffc27
📒 Files selected for processing (2)
src/hooks/useBreakpoint.tssrc/styles/mediaQuery.ts
jeonghoon11
left a comment
There was a problem hiding this comment.
고생하셨습니다!! 많은 레퍼런스 찾아보고 구현하신 티가 나네요 굿굿!!
| } | ||
|
|
||
| function subscribe(callback: () => void) { | ||
| const mqls = [ |
| const bp = useSyncExternalStore( | ||
| subscribe, | ||
| getBreakpoint, | ||
| () => 'desktop' as Breakpoint, |
There was a problem hiding this comment.
SSR에서는 window 없으니 세 번째 인자 'desktop'을 기본값으로 반환.
Admin 서비스여서 데탑 사용 비중이 높아서 SSR fallback을 desktop으로 지정하신건가용??
There was a problem hiding this comment.
네 맞습니다! default 값을 지정해야하는데 어드민이라 desktop으로 지정했어요!
| isTablet: bp === 'tablet', | ||
| isDesktop: bp === 'desktop', | ||
| isDesktopLarge: bp === 'desktopLarge', | ||
| isMobileOrTablet: bp === 'mobile' || bp === 'tablet', |
There was a problem hiding this comment.
isMobileOrTablet도 useBreakpoint의 return값에 넣은 이유가 궁금해요!
isTabletOrDesktop, isDesktopOrDesktopLarge처럼 조합 조건이 늘어날 수 있지 않을까 싶은데 공통 도메인 의미가 있는 조건이 아니라면 사용처에서 breakpoint 기준으로 판단하는건 어떤가 싶은데 진혁님의 생각은 어떤가요??
There was a problem hiding this comment.
사실 breakpoint.ts에서 두개 합친 것이 없긴해서 일관성 측면에서는 빼는게 맞긴하겠네요!
처음에는 디자인에서 breakpoint를 둘 때 tablet과 mobile을 동일하게 디자인하는 경우가 많아서 그렇게 뒀습니다!
There was a problem hiding this comment.
PR 디스크립션 잘 읽었습니다, 고생하셨어요!
사실 useState + useEffect가 저는 가장 리액트스러운 구현이라고는 생각해요.
- 일단 tearing이 발생할일은 절대 없을거같아요. useState + useEffect가 내재되어있는 Provider가 있고, 해당 프로바이더 하위로 media query 상태를 넘겨준다면 말이에요.
- css를 sync store로 담기에는 부적절하다고 느껴지긴 해요. 리액트가 아닌 외부 스토어와의 구독을 위해 사용하는 훅인데, css in js는 사실 리액트 구현체의 일부긴 하니까요.
mui와 같은 좋은 레퍼런스에서 채택하고 있기도 하고, 사실 성능 면에서 그렇게 큰 차이는 없다고 느껴져서 그대로 반영하셔도 이견은 없습니다~
css라고 생각하니 말씀해주신 것처럼 useState+useEffect가 나을 것 같기도 하네요! |
✨ 구현 기능 명세
✅ PR Point
1️⃣ PR Point: useBreakpoint 구현 방식 선택 근거
먼저 해당 훅을 구현하기위해 라이브러리 코드와 기존 makers 팀 (crew) 코드를 비교하고 가장 적합한 코드를 찾으려고 했어요.
크루는 breakpoint 하나당
useMediaQuery + useState + useIsomorphicLayoutEffect세트가 반복되는 구조에요. 그리고 next.js를 사용하기에 SSR hydration mismatch를 막기 위해useIsomorphicLayoutEffect를 사용하는 방식을 채택했어요.Chakra UI는
useState초기값으로 SSR/CSR을 분기하고,useEffect에서matchMedia.addEventListener로 변화를 구독하는 구조에요.MUI는 React 18 대응으로 명시적으로
useSyncExternalStore로 전환한 것을 확인할 수 있었어요. PR 제목이 "Ensure no tearing in React 18"이더라구요!PR 링크
Note
useSyncExternalStore와 tearing이란?
tearing: Concurrent Mode(동시성 모드)에서는 렌더링이 중단되고 재개될 수 있다. 그 사이에 외부 스토어가 변경되면 같은 화면에서 컴포넌트마다 서로 다른 데이터를 보여주는 불일치 현상이 발생할 수 있다. 이를 Tearing이라고 한다.
useState + useEffect 방식은 이를 막을 수 없음. 외부 상태 변화를 effect에서 뒤늦게 반영하기 때문.
useSyncExternalStore: React가 렌더링 도중에도 외부 스토어의 스냅샷을 동기적으로 확인하여 일관성을 보장. 세 번째 인자 getServerSnapshot으로 SSR 기본값도 명시적으로 선언 가능해 useIsomorphicLayoutEffect 트릭이 필요 없음.
즉 여러 라이브러리와 기존 팀의 코드를 살펴보면
useEffect/useLayoutEffect + useState의 조합이거나 MUI와 같이useSyncExternalStore를 쓰는 것을 볼 수 있었어요.media query 변화는 유저의 브라우저 창 조절 시에만 발생하기 때문에 tearing 실현 가능성 자체는 낮지만 아래와 같은 이유로 MUI와 같은
useSyncExternalStore방식을 채택했어요.이러한 이유로
useSyncExternalStore를 사용한 방식으로 구현했어요.2️⃣ PR Point: useBreakpoint 동작
이렇게 구현된 useBreakpoint의 동작을 오약하자면
요렇게 돼요! 저도 이 로직은 처음 구현해봐서,,, 정확히 이게 괜찮은지 잘 모르겠습니다,,,
리뷰와 코멘트 너무 환영입니다!!!!!!!!!!!!!!