본문으로 건너뛰기

lazy loading은 default export만 지원합니다.

· 약 12분
arch-spatula

React.lazy는 현재 default exports만 지원합니다. 다른 방법이 없습니다. 페이지 컴포넌트 단위 Code-Splitting이 현재 지식에서 최선이었습니다.

lazy loading

사실 기본 중 기본인데 교육 과정에서 아무도 안 알려줬습니다. 개발자가 야성을 길러야 하는 것은 맞지만 이것도 안알려주는 거는 좀... ㅂㄷㅂㄷ...

예전에 사용해보고 아주 좋았던 라이브러리가 있어서 또 사용하려고 합니다.

REACT SPINNERS

컴포넌트 활용이 상당히 직관적이어서 사용하기로 했습니다.

import { RouterProvider, createBrowserRouter } from 'react-router-dom';
import Layout from './GlobalLayout';
import { ROUTE_PATHS } from '../constant/config';
import { lazy } from 'react';

const Landing = lazy(() =>
import('../pages/Landing').then((module) => ({ default: module.Landing }))
);
const SignIn = lazy(() =>
import('../pages/SignIn').then((module) => ({ default: module.SignIn }))
);
const SignUp = lazy(() =>
import('../pages/SignUp').then((module) => ({ default: module.SignUp }))
);
const Cards = lazy(() =>
import('../pages/Cards').then((module) => ({ default: module.Cards }))
);
const Deck = lazy(() =>
import('../pages/Deck').then((module) => ({ default: module.Deck }))
);
const Setting = lazy(() =>
import('../pages/Setting').then((module) => ({ default: module.Setting }))
);
const NotFound = lazy(() =>
import('../pages/NotFound').then((module) => ({ default: module.NotFound }))
);

const routes = createBrowserRouter([
{
path: ROUTE_PATHS.WELCOME,
element: <Layout />,
children: [
{ index: true, element: <Landing /> },
{ path: ROUTE_PATHS.SIGN_IN, element: <SignIn /> },
{ path: ROUTE_PATHS.SIGN_UP, element: <SignUp /> },
{ path: ROUTE_PATHS.CARDS, element: <Cards /> },
{ path: ROUTE_PATHS.DECK, element: <Deck /> },
{ path: ROUTE_PATHS.SETTING, element: <Setting /> },
],
errorElement: <NotFound />,
},
]);

function Router() {
return <RouterProvider router={routes} />;
}

export default Router;

페이지 단위로 lazy loading만 적용한다고 끝나는 것이 아닙니다.

import { Global } from '@emotion/react';
import GlobalStyle from '../styles/Reset';
import { Navbar } from '../Components';
import { Outlet } from 'react-router-dom';
import { Suspense } from 'react';
import { PulseLoader } from 'react-spinners';

/**
* @see https://github.com/WANTED-TEAM03/pre-onboarding-10th-1-3/blob/main/src/routes/_globalLayout.tsx
*/
function Layout() {
return (
<>
<Global styles={GlobalStyle} />
<Navbar />
<Suspense
fallback={
<PulseLoader
color="#36d7b7"
cssOverride={{}}
loading
margin={4}
size={20}
speedMultiplier={0.5}
/>
}
>
<Outlet />
</Suspense>
</>
);
}

export default Layout;

로징을 처리하는 동안 보여줄 로더를 넣어야 합니다. 아래 이부분은 의도적으로 커밋하지 않을 것입니다. 커밋 전에 pull을 한번해야 편하게 진행할 수 있을 것 같습니다.

React.lazy는 현재 default exports만 지원합니다. named exports "default export", "컨테이너 쿼리", "container query",를 사용하고자 한다면 default로 이름을 재정의한 중간 모듈을 생성할 수 있습니다. 이렇게 하면 tree shaking이 계속 동작하고 사용하지 않는 컴포넌트는 가져오지 않습니다.

리액트 공식 문서를 읽는 중간에 발견했습니다.1

tree shaking을 위해 default exports로 변경해야 합니다.

로그인 상태관리

atomWithStorage

Warning: Cannot update a component (RouterProvider) while rendering a different component (Landing). To locate the bad setState() call inside ...

그냥 useEffect 사용하라는 에러였습니다.

에러메시지가 달라서 당황했습니다.

Warning: Cannot update a component (A) while rendering a different component (B). To locate the bad setState() call inside B, follow the stack trace as described in *** 에러 해결

// useLogin.tsx
import { atom, useAtom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';

const loggedIn = atom(false);
const tokenAtom = atomWithStorage('token', '');

export function useLogin() {
const [isLoggedIn, setIsLoggedIn] = useAtom(loggedIn);
const [token, setToken] = useAtom(tokenAtom);

const login = () => {
setIsLoggedIn(true);
};

const logout = () => {
setIsLoggedIn(false);
};

return { isLoggedIn, login, logout, token, setToken };
}

구현은 상당히 쉽습니다. 로그인 상태도 Storage의 토큰 문자열을 활용하면 됩니다. 비어있는 문자열은 로그아웃 상태 문자열이 있으면 로그인 상태로 보면 구현이 됩니다.

참고로 임시 로그인으로 만들었던 함수들도 이제 제거해야 합니다.

import { Link } from 'react-router-dom';
import { Nav, Container, List, ListItem } from './Navbar.style';
import { useLogin } from '../../hooks';

export function Navbar() {
const { token } = useLogin();
return <Nav>{token ? <LoggedInNav /> : <LoggedOutNav />}</Nav>;
}

새로고침을 해도 깜박임 없이 일단 처리가 됩니다.

import { useNavigate } from 'react-router-dom';
import { Button } from '../../Components';
import { useLogin } from '../../hooks';
import { ROUTE_PATHS } from '../../constant/config';
import { useEffect } from 'react';

function Landing() {
const { login, logout, token } = useLogin();
const navigate = useNavigate();
useEffect(() => {
if (token) {
navigate(ROUTE_PATHS.CARDS);
}
}, [token]);

return (
<div>
<h1>Welcome</h1>
<Button onClick={login}>login</Button>
<Button onClick={logout}>logout</Button>
</div>
);
}

export { Landing };

문제는 메인 페이지입니다. 메인 페이지에 suspense를 걸어야 합니다. 또 로직이 있다는 것도 문제입니다.

Jotai 유틸은 상당히 유용합니다.

카드 모델 클래스 구현 a.k.a 도메인 객체

도메인 객체는 비즈니스 로직의 부분을 독립적이게 캡슐화해야 합니다. 도메인에 해당하는 개념을 프로그래밍적으로 잘 표현해야 합니다.

독립적이기 때문에 테스트하기 쉬워야 합니다.

class CardRecord {
public question: string;
public answer: string;
public stackCount: number;
public submitDate: Date;
private _id?: string;
readonly userId?: string;

constructor(
question: string,
answer: string,
stackCount = -1,
submitDate = new Date(),
id?: string,
userId?: string
) {
this.question = question;
this.answer = answer;
this.stackCount = stackCount;
this.submitDate = submitDate;
this._id = id;
this.userId = userId;
}

get count() {
return this.stackCount;
}

get id() {
return this._id;
}
}

export class CardCollection {
private cardArr: CardRecord[];
constructor(...cards: CardRecord[]) {
this.cardArr = [...cards];
}

addCard(
question: string,
answer: string,
count: number,
submitDate: Date,
id?: string
) {
const card = new CardRecord(question, answer, count, submitDate, id);
this.cardArr.push(card);
}

deleteCard(id: string) {
this.cardArr = this.cardArr.filter((card) => card.id !== id);
}

editCard(id: string, question: string, answer: string) {
this.cardArr.map((card) => {
if (card.id === id) {
if (question) card.question = question;
if (answer) card.answer = answer;
} else return card;
});
}

checkAnswer(id: string, submit: string) {
const [card] = this.cardArr.filter((card) => card.id === id);
const regex = new RegExp(card.answer, 'i');
regex.test(submit) ? this.#correct(id) : this.#wrong(id);
}

#correct(id: string) {
const [card] = this.cardArr.filter((card) => card.id === id);
if (card.stackCount === -1) card.stackCount = 1;
else card.stackCount += 1;
}

#wrong(id: string) {
const [card] = this.cardArr.filter((card) => card.id === id);
card.stackCount = 0;
}

get items() {
return this.cardArr;
}
}

카드 도메인 객체를 만들었습니다. 어떤 정보의 갱신은 메서드를 통해서만 하도록 의도했습니다.

import { CardCollection } from './Cards';

describe('CardCollection', () => {
let cardCollection: CardCollection;

beforeEach(() => {
cardCollection = new CardCollection();
});

it('should add a card to the collection', () => {
const question = 'What is the capital of France?';
const answer = 'Paris';
const count = 0;
const submitDate = new Date();

cardCollection.addCard(question, answer, count, submitDate);

expect(cardCollection.items.length).toBe(1);
expect(cardCollection.items[0].question).toBe(question);
expect(cardCollection.items[0].answer).toBe(answer);
expect(cardCollection.items[0].stackCount).toBe(count);
expect(cardCollection.items[0].submitDate).toBe(submitDate);
});

it('should delete a card from the collection', () => {
const question = 'What is the capital of France?';
const answer = 'Paris';
const count = 0;
const submitDate = new Date();
const cardId = 'card-123';

cardCollection.addCard(question, answer, count, submitDate, cardId);

cardCollection.deleteCard(cardId);

expect(cardCollection.items.length).toBe(0);
});

it('should edit a card in the collection', () => {
const question = 'What is the capital of France?';
const answer = 'Paris';
const count = 0;
const submitDate = new Date();
const newQuestion = 'What is the capital of Germany?';
const newAnswer = 'Berlin';
const cardId = 'card-123';

cardCollection.addCard(question, answer, count, submitDate, cardId);

cardCollection.editCard(cardId, newQuestion, newAnswer);

expect(cardCollection.items[0].question).toBe(newQuestion);
expect(cardCollection.items[0].answer).toBe(newAnswer);
});

it('should update stackCount when checking answer', () => {
const question = 'What is the capital of France?';
const answer = 'Paris';
const count = 0;
const submitDate = new Date();
const cardId = 'card-123';

cardCollection.addCard(question, answer, count, submitDate, cardId);

// Correct answer
cardCollection.checkAnswer(cardId, 'paris');
expect(cardCollection.items[0].stackCount).toBe(1);

// Incorrect answer
cardCollection.checkAnswer(cardId, 'berlin');
expect(cardCollection.items[0].stackCount).toBe(0);
});
});

테스트를 위해 id를 입력할 수 있도록 했습니다.

비즈니스 로직이 아주 단순해서 다행입니다.

개념적으로 머릿속에 확실해서 도메인 객체는 금방만들었습니다.

컨테이너 쿼리

올해부턴 CSS 다르게 짬 ㅅㄱ (2022년 CSS 채신기술)

컨테이너 쿼리는 새로운 표준이라 예상하고 적용해봅니다.

.class-name {
color: red;
}

@container (max-width: 360px) {
.class-name {
color: green;
}
}

컨테이너 쿼리 단위

컨테이너 쿼리만 있는 것이 아닙니다. 이제는 사용할 수 있는 단위도 있습니다. cq단위입니다.

  • cqw : 쿼리 컨테이너 너비의 1%
  • cqh : 쿼리 컨테이너 높이의 1%
  • cqi : 쿼리 컨테이너 인라인 크기의 1%
  • cqb : 쿼리 컨테이너의 블록 크기의 1%
  • cqmin : cqi 또는 cqb 중 더 작은 값
  • cqmax : cqi 또는 cqb 중 더 큰 값

컨테이너 쿼리를 사용할 때는 설정도 있습니다. 무엇을 따르고 안 따를지 지정도 가능합니다.

container-type 에는 size, inline-size, normal 속성값이 존재한다.

inline-size : 인라인 레벨 기준으로 컨테이너를 적용. 요소의 width 값에 따라 반응형이 동작된다. size : 블록 레벨 기준으로 컨테이너를 적용. width 뿐만 아니라 height 값에 따라 반응형이 동작 된다. normal : 해당 값이 부여된 요소를 container에서 제외시킨다. 일종의 none 의미라고 보면 되겠다.

div {
container-name: div-container;
container-type: inline-size; /* 왠만한 상황에선 inline-size 로 이용한다고 보면 된다 */
}

/* 특정 container-name의 요소에 반응 */
@container div-container (min-width: 700px) {
div {
font-size: 2em;
}
}

🌟 @media는 이제 그만 ! 최신 @container 사용법

블로그 설명을 상당히 잘합니다.

이제 styled components에 적용할 수 있으면 됩니다.

문제는 그냥 지원하고 있지는 않습니다.

yarn add react-container-query

위 라이브러리를 설치해야 하는데 매번 새로운 라이브러리를 설치할 때마다 거부감이 있습니다.

그래서 일단 적용 보류를 결정했습니다.

Footnotes

  1. Code-Splitting