Open Source

React ClientOnly ์ปดํฌ๋„ŒํŠธ ๋งŒ๋“ค๊ธฐ (feat. useMount)

eddie0329 2023. 5. 6. 20:41
๋ฐ˜์‘ํ˜•

๐Ÿ“Œ ๋ชฉ์ฐจ

  • ์„œ๋ก 
  • ClientOnly ์ปดํฌ๋„ŒํŠธ๋Š” ๋ญ˜๊นŒ?
  • Next ๊ณต์‹ ๋ฌธ์„œ์— ๋‚˜์™€ ์žˆ๋Š” ClientOnly ์ปดํฌ๋„ŒํŠธ
  • Next ๊ณต์‹ ๋ฌธ์„œ์— ๋‚˜์™€ ์žˆ๋Š” ClientOnly ์ปดํฌ๋„ŒํŠธ์˜ ๋ฌธ์ œ์ 
  • Nuxt ๊ฐ™์€ ClientOnly ๋งŒ๋“ค๊ธฐ
  • ์ฝ”๋“œ ์ž์„ธํ•˜๊ฒŒ ๋œฏ์–ด๋ณด๊ธฐ
  • ๊ฒฐ๋ก 

 

๐Ÿ“Œ ์„œ๋ก 

 

SSR์„ ์‚ฌ์šฉํ•˜๋ฉด ํ•„์—ฐ์ ์œผ๋กœ ClientOnly ์ปดํฌ๋„ŒํŠธ๋ฅผ ์‚ฌ์šฉํ•ด์•ผ ํ•˜๋Š” ์ˆœ๊ฐ„์ด ์žˆ์–ด์š”. Browser์—์„œ๋งŒ ์žˆ๋Š” ์ •๋ณด๋กœ rendering์„ ํ•ด์•ผ ํ•˜๊ฑฐ๋‚˜ ์˜๋„์ ์œผ๋กœ ์„œ๋ฒ„์—์„œ ๊ทธ๋ ค์ง€์ง€ ์•Š๊ฒŒ ํ•˜๋Š” ์ˆœ๊ฐ„ ๋ง์ด์—์š”. ํŠนํžˆ ์ €๋Š” A, B ํ…Œ์ŠคํŒ…์„ ํ•  ๋•Œ ClientOnly์˜ ๋„์›€์„ ๋งŽ์ด ๋ฐ›๊ณ  ์žˆ์–ด์š”. ๊ตฌ๊ธ€ ์—๋„๋ฆญํ‹ฑ์Šค์˜ A, B ํ…Œ์ŠคํŒ…์—์„œ ๋”์„ ์กฐ์ž‘ํ•˜๊ฒŒ ๋˜๋Š”๋ฐ ์ด๋•Œ ClientOnly์˜ ๊ธฐ๋Šฅ์ด ์—†๋‹ค๋ฉด ์„œ๋ฒ„์—์„œ ๋‚ด๋ ค์ฃผ๋Š” Dom๊ณผ Hydration ์ดํ›„์˜ ๋”์ด ๋‹ฌ๋ผ์ง€๊ฒŒ ๋˜์–ด hydration fail ์—๋Ÿฌ๋ฅผ ๋งŒ๋‚˜๊ฒŒ ๋˜๊ฑฐ๋“ ์š”.

 

Nuxt์—์„œ๋Š” ClientOnly ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ž์ฒด ์ œ๊ณต์„ ํ•ด์ฃผ๊ณ  ์žˆ์ง€๋งŒ Next์—์„œ๋Š” ๋Œ€๋žต ์ ์ธ ๊ตฌํ˜„ ๋ฐฉ๋ฒ•์„ ๊ณต์‹ ๋ฌธ์„œ์—๋งŒ ๋‚˜์™€์žˆ์–ด์š”.  ๊ทธ๋Ÿฌ๋‚˜ ๋ฌธ์„œ์— ๋‚˜์™€ ์žˆ๋Š” ClientOnly๋Š” ๊ธฐ๋Šฅ์ด ์กฐ๊ธˆ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค. ๋‹ค์Œ ๊ธ€์—์„œ๋Š” ์ด ๋ฌธ์ œ๋ฅผ ์–ด๋–ป๊ฒŒ ๊ทน๋ณต์„ ํ–ˆ๋Š”์ง€๋ฅผ ๊ธฐ์ˆ ํ•ฉ๋‹ˆ๋‹ค.

 

๐Ÿ“Œ ClientOnly ์ปดํฌ๋„ŒํŠธ๋Š” ๋ญ˜๊นŒ?

 

์ง์ ‘์ ์ธ ๊ตฌํ˜„์— ์•ž์„œ ClientOnly ์ปดํฌ๋„ŒํŠธ๊ฐ€ ํ•˜๋Š” ์ผ์— ๋Œ€ํ•ด ๊ฐ„๋žตํ•˜๊ฒŒ ์„ค๋ช…ํ•ด๋ณผ๊ฒŒ์š”. 

 

Next์—์„œ์˜ ๊ธฐ๋ณธ์ ์ธ ๋™์ž‘ ๊ตฌ์กฐ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์•„์š”.

Request -> Next Server -> Static HTML -> Client -> HTML์— script ์ฝ”๋“œ ์‚ฝ์ž… (hydration)

๋จผ์ € ์‚ฌ์šฉ์ž๊ฐ€ Next Server์— ํŽ˜์ด์ง€ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค. ๊ทธ๋Ÿผ Next Server์—์„œ๋Š” ์š”์ฒญ์— ๋งž๋Š” ํŽ˜์ด์ง€๋ฅผ ๋งŒ๋“ค๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. 

 

๐Ÿ“Œ Next ๊ณต์‹ ๋ฌธ์„œ์— ๋‚˜์™€ ์žˆ๋Š” ClientOnly ์ปดํฌ๋„ŒํŠธ

 

Next ๊ณต์‹ ๋ฌธ์„œ์— ๋‚˜์™€ ์žˆ๋Š” ClientOnly ์ปดํฌ๋„ŒํŠธ๋Š” ์ด๋ ‡๊ฒŒ ๊ตฌํ˜„์ด ๋˜์–ด์žˆ์–ด์š”.

 

import React, { useState, useEffect } from 'react'

export default function ClientOnly({ children }) {
  const [isMounted, setIsMounted] = useState(false)
  useEffect(() => {
  	setIsMounted(true)
  }, [])
  
  if (isMounted) return <>{children}</>
  else return <></>
}

์ •๋ง ๊ฐ„๋‹จํ•˜๊ฒŒ ๊ตฌํ˜„์„ ํ•  ์ˆ˜ ์žˆ์ฃ ? ๊ทผ๋ฐ ์ด ์ปดํฌ๋„ŒํŠธ์˜ ๋ฌธ์ œ์ ์— ๋Œ€ํ•ด ์‚ดํŽด๋ณผ๊ฒŒ์š”.

 

๐Ÿ“Œ Next ๊ณต์‹ ๋ฌธ์„œ์— ๋‚˜์™€ ์žˆ๋Š” ClientOnly ์ปดํฌ๋„ŒํŠธ์˜ ๋ฌธ์ œ์ 

 

๋จผ์ € ๊ณต์‹ ๋ฌธ์„œ์— ๋‚˜์™€ ์žˆ๋Š” ClientOnly ์ปดํฌ๋„ŒํŠธ์˜ ๋ฌธ์ œ์ ์„ ์‚ดํŽด๋ณด๊ธฐ ์ „์— ์šฐ๋ฆฌ๊ฐ€ ์™œ SSR์„ ์‚ฌ์šฉํ•˜๋Š”์ง€ ์งš๊ณ  ๋„˜์–ด๊ฐˆ ํ•„์š”๊ฐ€ ์žˆ์–ด์š”.

 

์šฐ๋ฆฌ๋Š” ๋„๋Œ€์ฒด ์™œ SSR์„ ์‚ฌ์šฉํ• ๊นŒ์š”? ์•„๋งˆ ์ •๋ง ๋งŽ์€ ์ด์œ ๊ฐ€ ์žˆ์„ํ…๋ฐ, ๊ทธ์ค‘ ํ•˜๋‚˜๋Š” ๋ฐ”๋กœ ๊ฒ€์ƒ‰ ์—”์ง„ ์ตœ์ ํ™”๋ฅผ ํ•˜๊ธฐ ์œ„ํ•ด์„œ ์ž…๋‹ˆ๋‹ค. ๊ฒ€์ƒ‰ ์—”์ง„ ์ตœ์ ํ™”๋ฅผ ์œ„ํ•ด ์šฐ๋ฆฌ๋Š” ํฌ๋กค๋Ÿฌ ๋ด‡์—๊ฒŒ ํ•ด๋‹น ํŽ˜์ด์ง€๊ฐ€ ์–ด๋–ค ๋‚ด์šฉ์ด ๋“ค์–ด๊ฐ€ ์žˆ๋Š”์ง€ ์ตœ๋Œ€ํ•œ ๋งŽ์€ ์ •๋ณด๋ฅผ ์•Œ๋ ค์ค„ ํ•„์š”๊ฐ€ ์žˆ์–ด์š”. ๊ทผ๋ฐ ClientOnly ๋ถ€๋ถ„์€ ์ƒ๋žต์ด ๋  ๊ฑฐ์˜ˆ์š”. (๋ฌผ๋ก  ์š”์ฆ˜ ๊ตฌ๊ธ€ ํฌ๋กค๋Ÿฌ ๋ด‡์€ ์ผ๋ฐ˜์ ์ธ CSR์˜ ์ •๋ณด๋„ ๊ธ์–ด๊ฐ„๋‹ค์ง€๋งŒ ๋‹ค๋ฅธ ๊ฒ€์ƒ‰ ์‚ฌ์ดํŠธ์—์„œ๋Š” ๊ทธ ์ •๋„๊นŒ์ง€๋Š” ์ง€์›ํ•˜์ง€ ์•Š์•„์š”. ๐Ÿ˜ญ๐Ÿ˜ญ๐Ÿ˜ญ)

 

๊ทธ๋ ‡๋‹ค๋ฉด ์ด ๋ฌธ์ œ๋ฅผ ์–ด๋–ป๊ฒŒ ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ์„๊นŒ์š”? ๋ฐ”๋กœ ์„ค๋ช… ์ฝ˜ํ…์ธ ๋ฅผ ๋„ฃ์–ด์ฃผ๋Š” ๊ฑฐ์˜ˆ์š”

 

๐Ÿ“Œ Nuxt ๊ฐ™์€ ClientOnly ๋งŒ๋“ค๊ธฐ

์šฐ๋ฆฌ๋Š” ์•ž์„œ Next ๊ณต์‹ ๋ฌธ์„œ์— ๋‚˜์™€์žˆ๋Š” ClientOnly์˜ ๋ฌธ์ œ์ ์„ ์‚ดํŽด๋ณด์•˜์Šต๋‹ˆ๋‹ค. ๊ทธ๋ ‡๋‹ค๋ฉด ์ด ๋ฌธ์ œ๋Š” ์–ด๋–ป๊ฒŒ ํ•ด๊ฒฐ์„ ํ•  ์ˆ˜ ์žˆ์„๊นŒ์š”? ๋‹ต์€ Nuxt.js ์—์„œ ์ œ๊ณตํ•˜๋Š” ClientOnly์˜ interface์— ์žˆ์Šต๋‹ˆ๋‹ค.

 

interface ClientOnly {
  fallback: string
  fallbackTag?: 'div' | string 
}

 

๋„ค, Nuxt๋Š” ServerSide์—์„œ ๊ทธ๋ ค์งˆ ์ปดํฌ๋„ŒํŠธ์˜ ์„ค๋ช…์„ ์ฒจ๋ถ€ํ•  ์ˆ˜ ์žˆ์–ด์š”. ๊ทธ๋ž˜์„œ ์ด๋ ‡๊ฒŒ ์‚ฌ์šฉ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

 

// ServerSide์—์„œ๋Š” <div>hello</div> ๋งŒ ๋žœ๋”๋ง ํ•จ.
// ClientSide์—์„œ ์™”์„๋•Œ ๋น„๋กœ์†Œ <div>hello world</div>๋ฅผ ๋žœ๋”๋ง.
<ClientOnly fallback='hello'>
  <div>hello world</div>
</ClientOnly>

๊ทผ๋ฐ ์ €๋Š” ์—ฌ๊ธฐ์„œ ์ข€ ๋” ํ™•์žฅ์„ ํ•˜๊ณ  ์‹ถ์–ด์š”. 

  1. ๋ฆฌ์•กํŠธ ์ปดํฌ๋„ŒํŠธ๋„ fallback์œผ๋กœ ๋ฐ›์„ ์ˆ˜ ์žˆ์Œ -> ์กฐ๊ธˆ ๋” ๋ฆฌ์•กํŠธ ์Šค๋Ÿฌ์šด ๋А๋‚Œ
  2. fallback์—์„œ attribute๋ฅผ ์ง€์ •ํ•  ์ˆ˜ ์žˆ์Œ(ex> aria-label="hello") -> SEO๋ฅผ ๊ฐ•ํ™”๋ฅผ ์œ„ํ•ด

์ด์ œ ํ•ด๊ฒฐ์ฑ…๋„ ์•Œ์•˜์œผ๋‹ˆ ์šฐ๋ฆฌ๋„ react ์ฝ”๋“œ๋กœ ๋งŒ๋“ค์–ด ๋ณผ๊นŒ์š”? ์ „์ฒด ์ฝ”๋“œ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์•„์š”.

 

import React, { ReactElement, PropsWithChildren, useMemo, useEffect, useState } from 'react'

const useMount = (): boolean => {
  const [isMounted, setIsMounted] = useState(false)
  useEffect(() => {
    setIsMounted(true)
  }, [])
  return isMounted
}

const isString = (target: unknown): target is string => typeof target === 'string'

interface FallBackProps {
  fallbackTag?: keyof JSX.IntrinsicElements | 'div'
  fallback?: string | ReactElement
}

function FallBack({ fallback, fallbackTag = 'div', ...delegates }: FallBackProps): ReactElement {
  const FallBackTag = useMemo(() => `${fallbackTag}`, [fallbackTag]) as keyof JSX.IntrinsicElements
  if (fallback) {
    if (isString(fallback)) return <FallBackTag {...delegates}>{fallback}</FallBackTag>
    else return <>{fallback}</>
  }
  return <></>
}

export default function ClientOnly({ children, fallbackTag, fallback, ...delegates }: PropsWithChildren<FallBackProps>): ReactElement {
  const isMounted = useMount()
  if (isMounted) return <>{children}</>
  return <FallBack fallback={fallback} fallbackTag={fallbackTag} {...delegates} />
}

 

์ด๋ ‡๊ฒŒ ๊ตฌํ˜„์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. ๊ทธ๋Ÿผ ์ด์ œ ClientOnly๋ฅผ ์‚ฌ์šฉํ•˜๋Š”์ชฝ์—์„œ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์ด ํŽธํ•˜๊ฒŒ ์‚ฌ์šฉ์ด ๊ฐ€๋Šฅํ•  ๊ฑฐ์˜ˆ์š”.

 

// ๐Ÿ’ก 1๋ฒˆ ์ผ€์ด์Šค) fallback์„ string์œผ๋กœ ์‚ฌ์šฉํ•˜๋Š” ๊ฒฝ์šฐ:
// ServerSide: <div>hello world</div>
// ClientSide: <div>hello</div>
function Test() {
	return (
	<ClientOnly fallback={'hello world'}>
        	<div>hello</div>
        </ClientOnly>
	)
}

// ๐Ÿ’ก 2๋ฒˆ ์ผ€์ด์Šค) fallbackTag๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒฝ์šฐ
// ServerSide: <h1>hello world</h1>
// ClientSide: <div>hello</div>
function Test() {
	return (
	<ClientOnly fallback={'hello world'} fallbackTag='h1'>
        	<div>hello</div>
        </ClientOnly>
	)
}

// ๐Ÿ’ก 3๋ฒˆ์ผ€์ด์Šค) Component๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒฝ์šฐ
// ServerSide: <h1>hello world</h1>
// ClientSide: <div>hello</div>
function TestServer() {
	return <h1>hello world</h1>
}
function Test() {
	return (
	<ClientOnly fallback={<TestServer />}>
        	<div>hello</div>
        </ClientOnly>
	)
}

 

๊ทธ๋Ÿผ ์ด์ œ ์œ„ ์ฝ”๋“œ๋ฅผ ์ž์„ธํ•˜๊ฒŒ ๋œฏ์–ด๋ณผ๊ฒŒ์š”.

 

๐Ÿ“Œ ์ฝ”๋“œ ์ž์„ธํ•˜๊ฒŒ ๋œฏ์–ด๋ณด๊ธฐ

๋จผ์ € Fallback ์ปดํฌ๋„ŒํŠธ๋ฅผ ์‚ดํŽด๋ณผ๊ฒŒ์š”.

import React, { ReactElement, PropsWithChildren, useMemo } from 'react'

const isString = (target: unknown): target is string => typeof target === 'string'

interface FallBackProps {
  fallbackTag?: keyof JSX.IntrinsicElements | 'div'
  fallback?: string | ReactElement
}

// โญ๏ธ delegates๋Š” ์•ž์„œ ๋งํ•œ attributes๋ฅผ ์ƒ์„ฑํ•˜๊ธฐ ์œ„ํ•จ์ž…๋‹ˆ๋‹ค.
// ex> <FallBack fallback='hello' aria-label='hello' />
// -> <div aria-label='hello'>hello</div>
function FallBack({ fallback, fallbackTag = 'div', ...delegates }: FallBackProps): ReactElement {
  // โญ๏ธ Tag๋ฅผ ๋™์ ์œผ๋กœ ์ƒ์„ฑ์„ ํ•ด์ค๋‹ˆ๋‹ค.
  // props.fallbackTag ๊ฐ€ 'h1'์œผ๋กœ ๋“ค์–ด์™”๋‹ค๋ฉด h1ํƒœ๊ทธ๋ฅผ ๋งŒ๋“ค์–ด์ค˜์š”.
  const FallBackTag = useMemo(() => `${fallbackTag}`, [fallbackTag]) as keyof JSX.IntrinsicElements
  
  if (fallback) {
    // โญ๏ธ fallback์€ ๋‘๊ฐ€์ง€ ํƒ€์ž…(string, ReactElement)์ด ์žˆ์–ด์„œ ๋ถ„๊ธฐ์ฒ˜๋ฆฌ ๋ชจ์Šต์ž…๋‹ˆ๋‹ค.
    if (isString(fallback)) return <FallBackTag {...delegates}>{fallback}</FallBackTag>
    else return <>{fallback}</>
  }
  return <></>
}

 

๊ทธ๋Ÿผ ์ด์ œ ๋งˆ์ง€๋ง‰์œผ๋กœ ClientOnly ์ฝ”๋“œ๋ฅผ ์‚ดํŽด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค.

 

// โญ๏ธ useMount๋Š” isMounted๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋Š” ์ฝ”๋“œ๋กœ ์งœ์—ฌ์žˆ์–ด์š”. 
// hydration์ด ์ผ์–ด๋‚˜๋ฉด true๋กœ ๋ณ€๊ฒฝ์ด ๋ฉ๋‹ˆ๋‹ค.
// hydration์ด ์ผ์–ด๋‚ฌ๋‹ค๋Š” ๋ง์€ client-side๋ผ๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
const useMount = (): boolean => {
  const [isMounted, setIsMounted] = useState(false)
  useEffect(() => {
    setIsMounted(true)
  }, [])
  return isMounted
}

// โญ๏ธ ClientOnly ์ปดํฌ๋„ŒํŠธ๋Š” ๋‹จ์ˆœํ•˜๊ฒŒ isMounted์˜ ์—ฌ๋ถ€๋ฅผ ๋†“๊ณ  ์–ด๋–ป๊ฒŒ ๋žœ๋”๋งํ•  ์ง€ ๊ฒฐ์ •ํ•ฉ๋‹ˆ๋‹ค.
// server side ๋ผ๋ฉด Fallback ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋žœ๋”๋ง
// client side ๋ผ๋ฉด children์„ ๋žœ๋”๋ง
export default function ClientOnly({ children, fallbackTag, fallback, ...delegates }: PropsWithChildren<FallBackProps>): ReactElement {
  const isMounted = useMount()
  if (isMounted) return <>{children}</>
  return <FallBack fallback={fallback} fallbackTag={fallbackTag} {...delegates} />
}

๐Ÿ“Œ ๊ฒฐ๋ก 

์ด๋ ‡๊ฒŒ ClienyOnly๋ฅผ ์‚ฌ์šฉํ•ด์„œ Hydration error๋ฅผ ํ”ผํ•  ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. ์‚ฌ์‹ค use-mount ํŒจํ‚ค์ง€์˜ ๋ฐฐํฌ ๋‚ด์šฉ ์ค‘ ํ•˜๋‚˜์ž…๋‹ˆ๋‹ค. ์กฐ๊ธˆ ๋” ๋งŽ์€ ๊ธฐ๋Šฅ์„ ์‚ฌ์šฉํ•˜๊ณ  ์‹ถ์œผ์‹œ๋‹ค๋ฉด @react-useful-hooks/use-mount ๋ฅผ ์• ์šฉํ•ด ์ฃผ์„ธ์š”. (โญ๏ธ๋Š” ์‚ฌ๋ž‘์ž…๋‹ˆ๋‹ค!)

 

๊ทธ๋ฆฌ๊ณ  ํ˜น์‹œ react-hooks๊ด€๋ จ ์˜คํ”ˆ์†Œ์Šค์— ๊ด€์‹ฌ์ด ์žˆ์œผ์‹œ๋‹ค๋ฉด ์ €ํฌ ReactUsefulHooks์— ๋†€๋Ÿฌ ์™€ ์ฃผ์„ธ์š”. ์•ž์œผ๋กœ ๋” ๋งŽ์€ ์œ ์šฉํ•œ react-hooks๋ฅผ ์ œ๊ณตํ•  ์˜ˆ์ •์ด๋ผ ํ•จ๊ป˜ํ•˜๊ณ  ์‹ถ์œผ์‹œ๋‹ค๋ฉด ์–ธ์ œ๋“  ํ™˜์˜์ž…๋‹ˆ๋‹ค! ๐Ÿ™‡๐Ÿป‍โ™‚๏ธ

๋ฐ˜์‘ํ˜•