7. State 보존하기

2024. 6. 11. 22:30리액트 (React)/리액트 레시피 (React Recipt)

7장부터는 파일 구조가 변경되었습니다.

모든 예제 소스는 github repository 에서 확인할 수 있습니다.

 


Friends

 

UI에서 컴포넌트를 없앴다가 다시 나타나게 하는 상황을 상상해보았을 때,

해당 컴포넌트가 state를 쓰고 있었다면, 해당 state는 보존될까요? 아니면 다시 새롭게 생성될까요?

 

질문에 답하기 위해 기존 UI에 컴포넌트 몇 개를 더 만들어보죠.

 

위처럼 친구 목록을 만들어봅니다.

이름들은 상위 컴포넌트에서 받도록 props로 만들었습니다.

import "./Header.scss";

export default function Header() {
    return (
        <div className="friend-header">
            <span className="title">Chat Friends</span>
        </div>
    );
}
import Header from "./Header";
import "./List.scss";

export default function List({
    names,
    onClickListItem,
}: {
    names: string[];
    onClickListItem: (name: string) => void;
}) {
    return (
        <div className="layout">
            <div className="box">
                <Header />
                {names.map((name) => (
                    <div className="list-item" onClick={() => onClickListItem(name)}>
                        <span className="name">{name}</span>
                    </div>
                ))}
            </div>
        </div>
    );
}

 

이제 List 아니면 Box를 보여주도록 상위 컴포넌트를 하나 만들어줍니다.

여기서 친구 이름 목록이 있어, List에게 리스트업할 목록을 전달하거나 Box에게 클릭한 이름을 전달해줄 것입니다.

import { useMemo, useState } from "react";
import "./Main.scss";
import Box from "./chat/Box";
import List from "./friend/List";

export default function Main() {
    const friendNames = useMemo(() => ["Apple", "Carrot", "Lemon"], []);

    const [selectedName, setSelectedName] = useState<string>("");

    return (
        <div>
            {selectedName !== "" ? (
                <Box name={selectedName} />
            ) : (
                <List
                    names={friendNames}
                    onClickListItem={(name) => {
                        setSelectedName(name);
                    }}
                />
            )}
        </div>
    );
}

 

그리고 당연히 Box에서 다시 목록으로 돌아갈 수 있게끔 리스트 버튼을 만들어주고 onClick도 props에 추가해줍니다.

import "./Header.scss";

export default function Header({
    ...
    onClickList,  // 추가됨
}: {
    ...
    onClickList: () => void;  // 추가됨
}) {
    return (
        <div className="chat-header">
            <button className="menu-button" onClick={() => onClickList()}>  // 추가됨
                <i className="fa-solid fa-bars" />
            </button>
            <span className="title">{name}</span>
        </div>
    );
}
...

export default function Box({
    ...,
    onClickList = () => {},
}: {
    ...,
    onClickList?: () => void;
}) {
    const [newMessages, setNewMessages] = useState<string[]>([]);

    return (
        <div className="layout">
            <div className="box">
                <Header name={name} onClickList={onClickList} />
                ...
            </div>
        </div>
    );
}

 

마지막으로 Main에서 Box에 onClickList 콜백 함수를 넣어주면 됩니다.

export default function Main() {
    ...
    
    return (
        <div>
            {selectedName !== "" ? (
                <Box name={selectedName} onClickList={() => setSelectedName("")} />
            ) : (
                ...
            )}
        </div>
    );
}

 

이제 목록에서 이름을 누르면 채팅방으로 이동합니다.

 

 

이제 Box 화면에 기능 하나를 더 추가하려고 합니다.

근처 다른 친구와 채팅을 하려면 목록에 다시 방문할 필요 없이, 편리하게 아예 여기서 이동할 수 있도록 할 것 입니다.

 

이를 위해 state를 두 개 더 만들어봅니다.

export default function Main() {
    ...
    
    const [previousName, setPreviousName] = useState<string>("");
    const [nextName, setNextName] = useState<string>("");
    
    ...

 

그리고 선택한 이름(selectedName)에 대해 이전 이름과 다음 이름을 가져올 수 있도록 useEffect 훅을 사용합니다.

export default function Main() {
    const friendNames = useMemo(() => ["Apple", "Carrot", "Lemon"], []);

    const [selectedName, setSelectedName] = useState<string>("");
    const [previousName, setPreviousName] = useState<string>("");
    const [nextName, setNextName] = useState<string>("");

    useEffect(() => {
        if (selectedName === "") {
            return;
        }

        const nameIndex = friendNames.indexOf(selectedName);
        if (nameIndex === -1) {
            return;
        }

        if (nameIndex > 0) {
            setPreviousName(friendNames[nameIndex - 1]);
        } else {
            setPreviousName("");
        }
        if (nameIndex < friendNames.length - 1) {
            setNextName(friendNames[nameIndex + 1]);
        } else {
            setNextName("");
        }
    }, [friendNames, selectedName]);
    
    ...

 

이제 남은 건 버튼을 만들어서 이전 또는 다음 친구의 채팅방으로 이동할 수 있도록 하는 것입니다.

export default function Main() {
    ...
    
    return (
        <div>
            {selectedName !== "" ? (
                <>
                    {previousName && (
                        <button
                            className="box-friend previous"
                            onClick={() => setSelectedName(previousName)}
                        >
                            {previousName}
                            <i className="fa-solid fa-arrow-left" />
                        </button>
                    )}
                    <Box name={selectedName} onClickList={() => setSelectedName("")} />
                    {nextName && (
                        <button
                            className="box-friend next"
                            onClick={() => setSelectedName(nextName)}
                        >
                            <i className="fa-solid fa-arrow-right" />
                            {nextName}
                        </button>
                    )}
                </>
            ) : (
                ...
            )}
        </div>
    );
}

 

 

 

 

 

 

 

 


Preservation

 

끝난 줄 알았으나, 한 가지 이상한 점이 보입니다.

친구가 변경되었어도 메시지는 그대로 남아있기 때문이죠.

 

 

이는 Box에 있는 state가 보존되었기 때문인데, 다시금 Main 코드를 보면

export default function Main() {
    ...
    
    return (
        <div>
            {selectedName !== "" ? (
                <>
                    {...}
                    <Box name={selectedName} onClickList={() => setSelectedName("")} />
                    {...}
                </>
            ) : (
                ...
            )}
        </div>
    );
}

 

Box의 위치는 바뀌지 않았고 name props만 변경되었다는 걸 알 수 있습니다.

여기서 말하는 위치(position)이란 어떤 의미일까요?

 

위와 같은 트리를 렌더 트리(render tree)라고 합니다.

트리를 보면, Box에 name을 어떻게 주든 지 간에 Box는 그대로 해당 위치에 존재합니다.

즉, 컴포넌트는 렌더 트리 구조에서 같은 위치에 있는 한 state가 유지됩니다.

 

상황에 따라 버튼이 없어지거나 생기거나 하는 데, 그럼 트리에서의 위치도 바뀔까요?

 

 

아닙니다. 

조건부 렌더링에 의해 없어졌다 생겼다 하더라도, 해당 위치는 그대로 보존되기 때문에 동일합니다.

 

그렇다면 state를 보존하지 않기 위해 극단적으로 아래처럼 만든다고 하면 어떨까요?

export default function Main() {
    ...
    
    return (
        <div>
            {selectedName !== "" ? (
                <>
                    {...}
                    {selectedName === "Apple" ? (
                        <Box name="Apple" onClickList={() => setSelectedName("")} />
                    ) : selectedName === "Carrot" ? (
                        <Box name="Carrot" onClickList={() => setSelectedName("")} />
                    ) : (
                        <Box name="Lemon" onClickList={() => setSelectedName("")} />
                    )}
                    {...}
                </>
            ) : (
                ...
            )}
        </div>
    );
}

 

아쉽지만 여전히 Box는 같은 위치에 있습니다.

조건부 렌더링에 의해 위치가 달라지지 않는다는 걸 이제 이해할 수 있겠죠.

 

 

그렇다면 아래처럼 다른 태그로 감싸서 보는 건 어떨까요?

Apple, Carrot의 경우에는 div로 동일하게 놓고, Lemon만 span으로 두었습니다.

export default function Main() {
    ...
    
    return (
        <div>
            {selectedName !== "" ? (
                <>
                    {...}
                    {selectedName === "Apple" ? (
                        <div>
                            <Box name="Apple" onClickList={() => setSelectedName("")} />
                        </div>
                    ) : selectedName === "Carrot" ? (
                        <div>
                            <Box name="Carrot" onClickList={() => setSelectedName("")} />
                        </div>
                    ) : (
                        <span>
                            <Box name="Lemon" onClickList={() => setSelectedName("")} />
                        </span>
                    )}
                    {...}
                </>
            ) : (
                ...
            )}
        </div>
    );
}

 

 

Apple과 Carrot 사이에는 state가 보존됩니다.

 

 

하지만 Carrot에서 Lemon으로 가면 state가 보존되지 않죠.

 

div가 제거되고, span으로 교체되면서, Box의 위치는 변경되었기 때문입니다.

즉, 위치는 눈으로 보이는 그 장소뿐 아니라, 경로를 함께 보아야 하는 구조적인 시각이 필요합니다.

 

 

그렇다면 조건부 렌더링으로는 state를 초기화하지 못하는 걸까요.

만일 위치가 동일하더라도, key가 다르면 state를 초기화할 수 있습니다.

export default function Main() {
    ...
    
    return (
        <div>
            {selectedName !== "" ? (
                <>
                    {...}
                    {selectedName === "Apple" ? (
                        <Box key="Apple" name="Apple" onClickList={() => setSelectedName("")} />
                    ) : selectedName === "Carrot" ? (
                        <Box key="Carrot" name="Carrot" onClickList={() => setSelectedName("")} />
                    ) : (
                        <Box key="Lemon" name="Lemon" onClickList={() => setSelectedName("")} />
                    )}
                    {...}
                </>
            ) : (
                ...
            )}
        </div>
    );
}

 

 

 

즉 정리하면, state를 보존하는 열쇠는 위치(position)와 키(key) 입니다.

 

 

 

 

 

 

 


useEffect, useMemo

 

렌더링 될 때마다 특정 로직을 실행하고 싶을 때는 useEffect 훅을 사용합니다.

useEffect(() => {
    // run every rendering
}, []);

 

저 괄호는 의존성 배열로, 해당 배열에 state를 넣어두면

매 렌더링 때마다 배열 내 의존성이 1개라도 변경되었다면 로직을 실행합니다.

 

가령 본 예시에서는 이전 이름(previousName)과 다음 이름(nextName)을 만들 때 사용했었는데,

export default function Main() {
    ...

    useEffect(() => {
        if (selectedName === "") {
            return;
        }

        const nameIndex = friendNames.indexOf(selectedName);
        if (nameIndex === -1) {
            return;
        }

        if (nameIndex > 0) {
            setPreviousName(friendNames[nameIndex - 1]);
        } else {
            setPreviousName("");
        }
        if (nameIndex < friendNames.length - 1) {
            setNextName(friendNames[nameIndex + 1]);
        } else {
            setNextName("");
        }
    }, [friendNames, selectedName]);
    
    ...

 

이렇게 만들면 friendNames 또는 selectedName이 변경되서 렌더링 될 때,

useEffect에 작성한 로직이 실행되는 것입니다.

 

useMemo는 저 의존성 배열의 모든 변경 기록과 결과를 가지고 있어, (aka, 캐싱)

이미 있던 의존성 배열이라면 기록해뒀던 결과를 바로 제공합니다.

export default function Main() {
    const friendNames = useMemo(() => ["Apple", "Carrot", "Lemon"], []);
    
    ...

 

즉, useEffect와는 메모이제이션(memoization)을 사용한다는 차이가 있습니다.

 

 

 

 

 

 


References

 

 

Preserving and Resetting State – React

The library for web and native user interfaces

react.dev

 

Understanding Your UI as a Tree – React

The library for web and native user interfaces

react.dev

 

useMemo – React

The library for web and native user interfaces

react-ko.dev

 

 

 

 

'리액트 (React) > 리액트 레시피 (React Recipt)' 카테고리의 다른 글

9. Ref로 제어하기  (0) 2024.06.12
8. Context 전달하기  (0) 2024.06.12
6. 인터렉티브한 Input 만들기  (0) 2024.06.10
5. State로 리렌더링하기  (0) 2024.06.10
4. CSS 입히기  (0) 2024.06.10