Hits

서버 컴포넌트의 클라이언트 로직

서버 컴포넌트를 활용하여 개발을 하다보면 때때로 만들어 두었던 서버 컴포넌트에서 클라이언트 로직을 다뤄야 하는 경우가 있습니다.

제가 겪었던 상황은 아래와 같습니다.

  • 링크가 달린 카테고리버튼 컴포넌트가 존재 (AS-IS: Server Component)
  • 현재 선택된 카테고리와 같다면 ScrollIntoElement API를 활용해 포커싱 (TO-BE: 클라이언트 로직 추가)

이런 상황에서 해당 컴포넌트를 아래와 같이 클라이언트 컴포넌트로 만들어 해결할 수 있습니다.

AS-IS(Server Component)
"use client";
 
const CategoryButton = ({ category: { text, count } }: Props) => {
  return (
    <Link
      href={{
        pathname: "/",
        query: { category: text },
      }}
    >
      <span>{text}</span>
      <span>{count}</span>
    </Link>
  );
};
AS-IS(Server Component)
"use client";
 
const CategoryButton = ({ category: { text, count } }: Props) => {
  return (
    <Link
      href={{
        pathname: "/",
        query: { category: text },
      }}
    >
      <span>{text}</span>
      <span>{count}</span>
    </Link>
  );
};
TO-BE(Client Component)
"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>
  );
};
TO-BE(Client Component)
"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 를 활용해, 현재 선택된 카테고리인 경우 포커스 한다 라는 클라이언트의 로직을 분리할 수 있습니다.

ScrollIntoElement.tsx
// 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,
  });
};
ScrollIntoElement.tsx
// 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,
  });
};
CategoryButton.tsx
// 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>
  );
};
CategoryButton.tsx
// 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 메서드를 실행시키기 위해 useRefuseEffect훅을 사용했습니다.

ref의 타입선언을 확인한 적이 있다면, ref에 객체 뿐만 아니라 함수도 전달할 수 있습니다.

type Ref<T> = RefCallback<T> | RefObject<T> | null;
type Ref<T> = RefCallback<T> | RefObject<T> | null;

ref의 경우, Element의 참조를 위해 사용할수도 있지만, Element를 매게변수로 받는 콜백함수로 사용할 수도 있습니다. (onAfterMount)
따라서 callback Ref를 사용할 경우, useRefuseEffect훅을 사용하지 않고도 마운트 된 이후 focus(scrollIntoView)한다 라는 로직을 구현할 수 있습니다.

ScrollIntoElement.tsx
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,
  });
};
ScrollIntoElement.tsx
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