서버 컴포넌트의 클라이언트 로직
서버 컴포넌트를 활용하여 개발을 하다보면 때때로 만들어 두었던 서버 컴포넌트에서 클라이언트 로직을 다뤄야 하는 경우가 있습니다.
제가 겪었던 상황은 아래와 같습니다.
- 링크가 달린 카테고리버튼 컴포넌트가 존재 (AS-IS: Server Component)
- 현재 선택된 카테고리와 같다면
ScrollIntoElement
API를 활용해 포커싱 (TO-BE: 클라이언트 로직 추가)
이런 상황에서 해당 컴포넌트를 아래와 같이 클라이언트 컴포넌트로 만들어 해결할 수 있습니다.
"use client";
const CategoryButton = ({ category: { text, count } }: Props) => {
return (
<Link
href={{
pathname: "/",
query: { category: text },
}}
>
<span>{text}</span>
<span>{count}</span>
</Link>
);
};
"use client";
const CategoryButton = ({ category: { text, count } }: Props) => {
return (
<Link
href={{
pathname: "/",
query: { category: text },
}}
>
<span>{text}</span>
<span>{count}</span>
</Link>
);
};
"use client";
const CategoryButton = ({
category: { text, count },
isCurrentSelectedCategory,
}: Props) => {
const ref = useRef<Element | null>(null);
useEffect(() => {
if (!ref.current) {
return;
}
if (isCurrentSelectedCategory) {
ref.current.scrollIntoView({
block: "nearest",
inline: "center",
});
}
}, [scrollOptions, isCurrentSelectedCategory]);
return (
<Link
ref={ref}
href={{
pathname: "/",
query: { category: text },
}}
>
<span>{text}</span>
<span>{count}</span>
</Link>
);
};
"use client";
const CategoryButton = ({
category: { text, count },
isCurrentSelectedCategory,
}: Props) => {
const ref = useRef<Element | null>(null);
useEffect(() => {
if (!ref.current) {
return;
}
if (isCurrentSelectedCategory) {
ref.current.scrollIntoView({
block: "nearest",
inline: "center",
});
}
}, [scrollOptions, isCurrentSelectedCategory]);
return (
<Link
ref={ref}
href={{
pathname: "/",
query: { category: text },
}}
>
<span>{text}</span>
<span>{count}</span>
</Link>
);
};
하지만 서버 컴포넌트를 클라이언트 컴포넌트로 전환한다면 해당 컴포넌트는 클라이언트로 서빙되는 JS 번들파일에 포함시키게 되어 JS 번들파일의 사이즈를 키우게 됩니다.
따라서, 서버 컴포넌트를 활용하기로 한 시점에서
- 서버 컴포넌트를 클라이언트 컴포넌트로 전환하는 방향을 최대한 지양하고
- 각각의 로직이 적절하게 분리가 가능한 상황에서는 클라이언트 로직은 클라이언트 컴포넌트로 서버 로직은 서버 컴포넌트로 분리하는것을 지향했습니다.
단, 아래의 경우에는 분리하지 않았습니다.
- 컴포넌트 사이즈가 크지 않아, 분리로 인한 Overhead가 더 큰 경우
- 컴포넌트가 다루어야 하는 데이터와 강하게 바인딩 되어 있는 로직인 경우
React.cloneElement 활용
클라이언트 컴포넌트의 하위 트리에 서버 컴포넌트가 존재하기 위해서는, 클라이언트 컴포넌트의 children prop
을 활용해야 합니다. → Supported Pattern: Passing Server Components to Client Components as Props
children prop
을 사용해야 하는 경우에는 React
의 여러 API (createElement
, cloneElement
, createProtal
, ...) 를 이용해 로직을 분리할 수 있습니다.
위에서 언급한 카테고리 버튼 컴포넌트의 예시에서는 React.cloneElement
API 를 활용해, 현재 선택된 카테고리인 경우 포커스 한다
라는 클라이언트의 로직을 분리할 수 있습니다.
// Client Component
const ScrollInToElement = ({ children, when, scrollOptions }: Props) => {
const child = React.Children.only(children);
const childRef = useRef<Element | null>(null);
useEffect(() => {
if (!childRef.current) {
return;
}
if (when) {
childRef.current.scrollIntoView(scrollOptions);
}
}, [scrollOptions, when]);
return React.cloneElement(child, {
ref: childRef,
});
};
// Client Component
const ScrollInToElement = ({ children, when, scrollOptions }: Props) => {
const child = React.Children.only(children);
const childRef = useRef<Element | null>(null);
useEffect(() => {
if (!childRef.current) {
return;
}
if (when) {
childRef.current.scrollIntoView(scrollOptions);
}
}, [scrollOptions, when]);
return React.cloneElement(child, {
ref: childRef,
});
};
// Server Component
const CategoryButton = ({
category: { text, count },
isCurrentSelectedCategory,
}: Props) => {
return (
<ScrollInToElement
when={isCurrentSelectedCategory}
key={text}
scrollOptions={{
block: "nearest",
inline: "center",
}}
>
<Link
ref={ref}
href={{
pathname: "/",
query: { category: text },
}}
>
<span>{text}</span>
<span>{count}</span>
</Link>
</ScrollInToElement>
);
};
// Server Component
const CategoryButton = ({
category: { text, count },
isCurrentSelectedCategory,
}: Props) => {
return (
<ScrollInToElement
when={isCurrentSelectedCategory}
key={text}
scrollOptions={{
block: "nearest",
inline: "center",
}}
>
<Link
ref={ref}
href={{
pathname: "/",
query: { category: text },
}}
>
<span>{text}</span>
<span>{count}</span>
</Link>
</ScrollInToElement>
);
};
클라이언트 로직을 기존 서버 컴포넌트로부터 분리함으로써, 서버 컴포넌트의 이점을 그대로 가지고 갈 수 있게 되었습니다.
추가 팁 (callback ref)
위에서는 컴포넌트가 렌더링 된 이후(after mount) Element
에 결합된 scrollIntoView
메서드를 실행시키기 위해 useRef
와 useEffect
훅을 사용했습니다.
ref
의 타입선언을 확인한 적이 있다면, ref에 객체 뿐만 아니라 함수도 전달할 수 있습니다.
type Ref<T> = RefCallback<T> | RefObject<T> | null;
type Ref<T> = RefCallback<T> | RefObject<T> | null;
ref
의 경우, Element
의 참조를 위해 사용할수도 있지만, Element
를 매게변수로 받는 콜백함수로 사용할 수도 있습니다. (onAfterMount
)
따라서 callback Ref
를 사용할 경우, useRef
와 useEffect
훅을 사용하지 않고도 마운트 된 이후 focus(scrollIntoView)
한다 라는 로직을 구현할 수 있습니다.
const ScrollInToElement = ({ children, when, scrollOptions }: Props) => {
const child = React.Children.only(children);
const scrollIntoElement = useCallback(
(node: Element | null) => {
if (node === null) {
return;
}
if (when) {
node.scrollIntoView(scrollOptions);
}
},
[scrollOptions, when]
);
return React.cloneElement(child, {
ref: scrollIntoElement,
});
};
const ScrollInToElement = ({ children, when, scrollOptions }: Props) => {
const child = React.Children.only(children);
const scrollIntoElement = useCallback(
(node: Element | null) => {
if (node === null) {
return;
}
if (when) {
node.scrollIntoView(scrollOptions);
}
},
[scrollOptions, when]
);
return React.cloneElement(child, {
ref: scrollIntoElement,
});
};
Reference
- asChild pattern: https://www.jacobparis.com/content/react-as-child
- Avoiding useEffect with callback refs : https://tkdodo.eu/blog/avoiding-use-effect-with-callback-refs