'use client'

import moment from 'moment-timezone'
import { useEffect, useState } from 'react'

type Props = {
  date: Date
  format?: string | undefined
}

const UTCToLocal = ({ date, format = 'YYYY-MM-DD HH:mm:ss' }: Props) => {
  const [dateString, setDateString] = useState(moment.utc(date).format(format))

  useEffect(() => {
    setDateString(moment.utc(date).tz(moment.tz.guess()).format(format))
  }, [date, format])

  return <>{dateString}</>
}
export default UTCToLocal

UTCToLocal - 범용적인 타임존 처리방법
tsx

구글 검색봇이 크롤링하는 시간값의 조건과 클라이언트에서 사용자가 보는 타임존 시간표기를 모두 달성하기 위한 테크닉입니다.

nextjs
timezone
SEO
import { useInfiniteQuery } from '@tanstack/react-query'
import MobileHeader from '../../layouts/NhLayout/MobileHeader'
import { userPostListQueryKey } from '@/queries/user-queries'
import { NH_BIZ, NH_CATEGORY_NEWS } from '../../main'
import { css } from '@emotion/react'
import {
  WindowScroller as _WindowScroller,
  AutoSizer as _AutoSizer,
  List as _List,
  CellMeasurer as _CellMeasurer,
  CellMeasurerCache,
  WindowScrollerProps,
  AutoSizerProps,
  ListProps,
  CellMeasurerProps,
} from 'react-virtualized'
import { FC, useCallback, useEffect } from 'react'
import { SessionStorage } from '@/utils/storage-utils'
import { color, reset } from '@/styles/mixins'
import Link from 'next/link'
import CommonPostItem from './CommonPostItem'
import Loading from '@/components/user/ui-components/Loading'
import { CircularProgress } from '@mui/material'
import { momentKR } from '@/utils/basic-utils'
import DateSeparator from './DateSeparator'
import NewsPostItem from './NewsPostItem'
import NewChip from '../../components/NewChip'
import Router from 'next/router'
import { postService } from '@/services'

const biz = NH_BIZ

const WindowScroller = _WindowScroller as unknown as FC<WindowScrollerProps>
const AutoSizer = _AutoSizer as unknown as FC<AutoSizerProps>
const List = _List as unknown as FC<ListProps>
const CellMeasurer = _CellMeasurer as unknown as FC<CellMeasurerProps>

const cache = new CellMeasurerCache({
  fixedWidth: true,
  // defaultHeight: 417,
})

const getEnglishLocaleDateStr = (dateStr: string) => {
  return momentKR(dateStr).locale('En').format('YYYY.MM.DD ddd').toUpperCase()
}

const styles = {
  wrapper: css({ paddingBottom: 30 }),
  box: css({}),
}

type Props = {
  categorySlug: string
}

const NhPostList = ({ categorySlug }: Props) => {
  const pageSize = categorySlug === NH_CATEGORY_NEWS ? 10 : 5

  const {
    data: infiniteQueryResult,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = useInfiniteQuery({
    queryKey: [...userPostListQueryKey(biz), 'category-infinite', biz],
    queryFn: ({ pageParam }) =>
      postService.getPostListByCategoryForUser(biz, {
        slug: categorySlug,
        pageSize,
        pageNumber: pageParam,
      }),
    initialPageParam: 0,
    getNextPageParam: (lastPage, allPages) => {
      if (lastPage.result.items.length < pageSize) {
        return undefined
      }

      return allPages.length
    },
  })

  const data = infiniteQueryResult?.pages.map((page) => page.result.items).flat() ?? []

  const rowRenderer: ListProps['rowRenderer'] = ({ index, key, parent, style }) => {
    const prevIndex = index - 1

    const post = data[index]
    const prevPost = data[prevIndex]

    const postDateStr = getEnglishLocaleDateStr(post.publishedAt)
    const prevPostDateStr = prevPost ? getEnglishLocaleDateStr(prevPost.publishedAt) : ''

    return (
      <CellMeasurer cache={cache} parent={parent} key={key} columnIndex={0} rowIndex={index}>
        {({ registerChild }) => (
          <div ref={registerChild as any} style={style}>
            {categorySlug === NH_CATEGORY_NEWS ? (
              <>
                {postDateStr !== prevPostDateStr ? <DateSeparator dateStr={postDateStr} /> : null}
                <Link href={`/nh/post/view/${post.slug}`} css={[reset.link]}>
                  <NewsPostItem
                    post={post}
                    isNew={index === 0 && momentKR(post.publishedAt).isSame(momentKR(), 'day')}
                  />
                </Link>
              </>
            ) : (
              <div css={{ marginTop: 30 }}>
                <Link href={`/nh/post/view/${post.slug}`} css={[reset.link]} data-id={post.id}>
                  <CommonPostItem post={post} isNew={momentKR(post.publishedAt).isAfter(momentKR().subtract(1, 'd'))} />
                </Link>
              </div>
            )}
          </div>
        )}
      </CellMeasurer>
    )
  }

  const handleList = useCallback((node: _List) => {
    if (node) {
      cache.clearAll() // 진입시 셀 재 측정을 위해 캐시 삭제
      node.forceUpdate()
      node.measureAllRows()

      window.setTimeout(() => {
        window.scrollTo(0, SessionStorage.postScrollY)
      }, 0)
    }
  }, [])

  useEffect(() => {
    let lastWindowInnerWidth = window.innerWidth
    const resizeHandler = () => {
      const currentWindowInnerWidth = window.innerWidth

      if (currentWindowInnerWidth !== lastWindowInnerWidth) {
        cache.clearAll()
        lastWindowInnerWidth = currentWindowInnerWidth
      }
    }
    const scrollEventHandler = () => {
      //! homee으로 Pop 시에 스크롤 맨 위로가므로 초기에 스크롤 저장하지 않게 하기위함
      if (window.scrollY !== 0) {
        SessionStorage.postScrollY = window.scrollY
      }
    }

    window.addEventListener('resize', resizeHandler)
    window.addEventListener('scroll', scrollEventHandler)

    return () => {
      window.removeEventListener('resize', resizeHandler)
      window.removeEventListener('scroll', scrollEventHandler)
    }
  }, [])

  if (isLoading) return <Loading pageLoading />

  return (
    <>
      <MobileHeader categorySlug={categorySlug} customPrev={() => Router.push('/nh')} />
      <WindowScroller>
        {({ height, scrollTop, isScrolling, onChildScroll, registerChild }) => (
          <div ref={registerChild as any} css={styles.wrapper}>
            <AutoSizer disableHeight>
              {({ width }) => (
                <>
                  <List
                    ref={handleList}
                    autoHeight
                    height={height}
                    width={width}
                    isScrolling={isScrolling}
                    overscanRowCount={0}
                    onScroll={onChildScroll}
                    scrollTop={scrollTop}
                    rowCount={data.length}
                    rowHeight={cache.rowHeight}
                    rowRenderer={rowRenderer}
                    defferedMeasurementCache={cache}
                    //! Hydration 경고 해제용 See: https://github.com/bvaughn/react-virtualized/issues/1737#issuecomment-1219820104
                    style={{ overflowY: 'auto' }}
                  />
                </>
              )}
            </AutoSizer>
            {hasNextPage ? (
              <button
                type="button"
                disabled={isFetchingNextPage}
                css={[
                  reset.button,
                  {
                    width: '100%',
                    border: `1px solid ${color.BB30}`,
                    borderRadius: 10,
                    padding: `10px 0`,
                    fontSize: 18,
                    lineHeight: 1.5,
                    color: color.BB700,
                    marginTop: 30,
                  },
                ]}
                onClick={() => fetchNextPage()}
              >
                {isFetchingNextPage ? <CircularProgress size="1em" color="inherit" /> : '더 보기'}
              </button>
            ) : null}
          </div>
        )}
      </WindowScroller>
    </>
  )
}
export default NhPostList

렌더링 최적화 기술 - Virtualization
tsx

Virtualization 이라는 기술을 통해, 뷰포트의 컴포넌트만 렌더링하여 인터렉션 반응속도를 향상시킵니다.

최적화
react
react-virtualized
import { ReactNode } from 'react'

type HeadCell<ListItem> = {
  id: Extract<keyof ListItem, string>
  label: string
}

type Props<ListItem> = {
  headCells: HeadCell<ListItem>[]
  items: ListItem[]
  renderItem: (item: ListItem) => ReactNode
  page?: number
  pageSize?: number
}

const Table = <ListItem extends { id: string | number }>({
  headCells,
  items,
  renderItem,
  page = 0,
  pageSize = 0,
}: Props<ListItem>) => {
  return (
    <div className="overflow-x-auto">
      <table className="table">
        {/* head */}
        <thead>
          <tr>
            <th></th>
            {headCells.map((headCell) => (
              <th key={headCell.id}>{headCell.label}</th>
            ))}
          </tr>
        </thead>
        <tbody>
          {items.map((item, idx) => (
            <tr key={item.id}>
              <th>{(page - 1) * pageSize + (idx + 1)}</th>
              {renderItem(item)}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}
export default Table

Generic 컴포넌트 사용 예시(테이블)
tsx

상황에 따라 컴포넌트의 props 타입이 변경되도록 하기위한 테크닉입니다.

typescript
generic
table
function* findIndexAll(str: string, searchElement: string) {
  let foundIndex = -1

  do {
    foundIndex = str.indexOf(searchElement, foundIndex + 1)

    if (foundIndex !== -1) {
      yield foundIndex
    }
  } while (foundIndex > -1)
}

[...findIndexAll('/admin/careers/codes', '/')]

Generator로 findIndexAll 구현
ts

문자열에서 찾고자 하는 대상의 모든 인덱스를 반환하는 Generator입니다.

es6
generator
import { css } from '@emotion/react'
import { ButtonHTMLAttributes, forwardRef, useCallback, useEffect, useRef } from 'react'

const styles = {
  wrapper: css({
    '*': {
      pointerEvents: 'none',
    },
  }),
}

export type GAButtonProps = ButtonHTMLAttributes<HTMLButtonElement>

const GAButton = forwardRef<HTMLButtonElement, GAButtonProps>(
  ({ id, children, 'aria-label': ariaLabel, ...rest }: GAButtonProps, forwardedRef) => {
    const inRef = useRef<HTMLButtonElement | null>(null)

    const assignRef = useCallback(
      (node: HTMLButtonElement) => {
        if (forwardedRef) {
          if (typeof forwardedRef === 'function') {
            forwardedRef(node)
          } else {
            forwardedRef.current = node
          }
        }
        inRef.current = node
      },
      [forwardedRef]
    )

    useEffect(() => {
      const button = inRef.current

      if (button) {
        const slugifiedButtonText = (ariaLabel ?? button?.textContent ?? '')?.trim().replaceAll(' ', '-')

        if (!id) {
          button.setAttribute('id', `button--${slugifiedButtonText}`)
        }
        const textColor = window.getComputedStyle(button).color
        button.style.setProperty('--text-color', textColor)
      }
    }, [ariaLabel, children, id])

    return (
      <button ref={assignRef} id={id} aria-label={ariaLabel} css={styles.wrapper} {...rest}>
        {children}
      </button>
    )
  }
)

GAButton.displayName = 'GAButton'

export default GAButton

forward된 ref와 컴포넌트 자체 ref를 동시에 다루는 법
tsx

자체 제작한 컴포넌트에 ref prop을 할당하기 위해서는 , forwardRef라는 기술을 사용해야 합니다.

react
forwardRef
import { ReactNode, useEffect, useState } from 'react'
import { Portal } from 'react-portal'

export const PORTAL_ID = 'portal'

const MyPortal = ({ children }: { children: ReactNode }) => {
  const [initialized, setInitialized] = useState(false)

  useEffect(() => {
    setInitialized(true)
  }, [])

  return initialized ? <Portal node={document && document.getElementById(PORTAL_ID)}>{children}</Portal> : null
}
export default MyPortal

React Portal의 SSR 대응법
tsx

react-portal 라이브러리가 Next.js의 서버사이드에서도 동작하도록 하기위한 코드입니다.

typescript
react
react-portal
next.js
// index.tsx

import { rem, typo, color as colorMixin } from '@/styles/mixins'
import { css } from '@emotion/react'
import Slot from './Slot'

const wrapperStyle = css([
  typo.HS28,
  {
    height: '1em',
    lineHeight: 1,
    fontVariantNumeric: 'tabular-nums',
    fontSize: `var(--font-size)`,
    color: `var(--color)`,
    overflow: 'hidden',
  },
])

type Props = {
  className?: string
  num?: number
  fontSize?: number
  color?: string
}

const SlotMachine = ({ className, num, fontSize = 28, color = colorMixin.DB1000 }: Props) => {
  return (
    <div
      className={className}
      aria-label={`${num}`}
      css={wrapperStyle}
      style={{ ['--font-size' as any]: rem(fontSize), ['--color' as any]: color }}
    >
      {num
        ? num
            .toLocaleString()
            .split('')
            .map((digitStr, index) => {
              //! toLocaleString이 Number와 ,만 있다고 가정
              if (digitStr === ',') {
                return digitStr
              }

              const digit = Number(digitStr)

              return <Slot key={index} digit={digit} index={index} />
            })
        : null}
    </div>
  )
}
export default SlotMachine

// Slot.tsx

import { keyframes } from '@emotion/react'
import { Fragment } from 'react'

type Props = {
  digit: number
  index: number
}

const transformNone = keyframes`
		to {
			transform: none
		}
	`

const easeOutQuart = 'cubic-bezier(0.25, 1, 0.5, 1)'
const easeInOutQuint = 'cubic-bezier(0.83, 0, 0.17, 1)'
const easeInOutExpo = 'cubic-bezier(0.87, 0, 0.13, 1)'

const Slot = ({ digit, index }: Props) => {
  return (
    <span
      css={{
        position: 'relative',
        top: `var(--top, 0)`,
        display: 'inline-flex',
        flexDirection: 'column',
        transform: `var(--transform)`,
        animation: `${transformNone} 6s ${easeInOutQuint} forwards`,
      }}
      style={{
        ['--top' as any]: -1 * digit + -10 * index + 'em', // 도착점
        ['--transform' as any]: `translateY(calc((100% - ${10 - digit}em)))`, // 0으로 튀고자 함
      }}
    >
      {[...Array(index + 1)].map((_, i) => (
        <Fragment key={i}>
          <span>0</span>
          <span>1</span>
          <span>2</span>
          <span>3</span>
          <span>4</span>
          <span>5</span>
          <span>6</span>
          <span>7</span>
          <span>8</span>
          <span>9</span>
        </Fragment>
      ))}
    </span>
  )
}
export default Slot

슬롯머신 컴포넌트
tsx

슬롯머신 애니메이션을 구현한 컴포넌트입니다.

react
ui
'use client'

import { THEME_DARK, THEME_LIGHT } from '@/constants'
import { useThemeStore } from '@/stores'
import { ChangeEventHandler, useCallback, useEffect, useRef } from 'react'

const ToggleTheme = () => {
  const toggleThemeRef = useRef<HTMLInputElement>(null)
  const changeGlobalThemeState = useThemeStore((state) => state.change)

  const setToggleStateByTheme = (theme: string) => {
    switch (theme) {
      case THEME_LIGHT:
        toggleThemeRef.current!.checked = true
        break
      case THEME_DARK:
        toggleThemeRef.current!.checked = false
        break
      default:
        toggleThemeRef.current!.checked = true
    }
  }

  const setTheme = useCallback(
    (theme: string) => {
      document.documentElement.dataset.theme = theme
      setToggleStateByTheme(theme)
      changeGlobalThemeState(theme)
    },
    [changeGlobalThemeState],
  )

  const toggleTheme: ChangeEventHandler<HTMLInputElement> = (e) => {
    const checked = e.target.checked

    const newTheme = checked ? THEME_LIGHT : THEME_DARK
    setTheme(newTheme)
  }

  useEffect(() => {
    setTheme(document.documentElement.dataset.theme ?? THEME_LIGHT)

    const colorSchemePreferenceChangeEventHandler = (event: MediaQueryListEvent) => {
      const newTheme = event.matches ? THEME_DARK : THEME_LIGHT
      setToggleStateByTheme(newTheme)
      setTheme(newTheme)
    }

    window
      .matchMedia('(prefers-color-scheme: dark)')
      .addEventListener('change', colorSchemePreferenceChangeEventHandler)

    return () => {
      window
        .matchMedia('(prefers-color-scheme: dark)')
        .removeEventListener('change', colorSchemePreferenceChangeEventHandler)
    }
  }, [setTheme])

  return (
    <label className="btn btn-square btn-ghost swap swap-rotate">
      {/* this hidden checkbox controls the state */}
      <input ref={toggleThemeRef} type="checkbox" onChange={toggleTheme} />

      {/* sun icon */}
      <svg className="swap-on h-6 w-6 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
        <path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z" />
      </svg>

      {/* moon icon */}
      <svg className="swap-off h-6 w-6 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
        <path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z" />
      </svg>
    </label>
  )
}
export default ToggleTheme

테마 토글 버튼(Daisy UI)
tsx

렌더링 최적화를 포함해, 테마를 토글하기 위한 모든 기능이 래핑되어 있는 컴포넌트입니다.

최적화
theme
toggle
zustand
tailwindCSS
'use client'

import MyPortal from '@/components/MyPortal'
import { LINK_BLOG, LINK_GITHUB, LINK_NOTION } from '@/constants'
import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline'
import Link from 'next/link'
import { ChangeEventHandler, MouseEventHandler, useState } from 'react'

const ToggleMenu = () => {
  const [opened, setOpened] = useState(false)

  const handleInputChanged: ChangeEventHandler<HTMLInputElement> = (e) => {
    setOpened(e.target.checked)
  }

  const handleMenuClicked: MouseEventHandler<HTMLAnchorElement> = (e) => {
    setOpened(false)
  }

  return (
    <>
      <div className="flex-none lg:hidden">
        <label htmlFor="drawer" aria-label="open sidebar" className="btn btn-square btn-ghost">
          <svg
            xmlns="http://www.w3.org/2000/svg"
            fill="none"
            viewBox="0 0 24 24"
            className="inline-block h-6 w-6 stroke-current"
          >
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16"></path>
          </svg>
        </label>
      </div>

      <MyPortal>
        <input id="drawer" type="checkbox" className="drawer-toggle" checked={opened} onChange={handleInputChanged} />
        <div className="drawer-side z-20">
          <label htmlFor="drawer" aria-label="close sidebar" className="drawer-overlay"></label>
          <ul className="menu min-h-full w-80 bg-base-200 p-4">
            {/* Sidebar content here */}
            <li>
              <Link href="/careers/intro" onClick={handleMenuClicked}>
                Intro
              </Link>
            </li>
            <li>
              <Link href="/careers/experiences" onClick={handleMenuClicked}>
                Experiences
              </Link>
              <ul>
                <li>
                  <Link href="/careers/experiences/portfolio" onClick={handleMenuClicked}>
                    Portfolio
                  </Link>
                </li>
                <li>
                  <Link href="/careers/experiences/codes" onClick={handleMenuClicked}>
                    Codes
                  </Link>
                </li>
                <li>
                  <a href={LINK_GITHUB} target="_blank" rel="noreferrer noopener nofollow">
                    Github <ArrowTopRightOnSquareIcon className="h-4 w-4" />
                  </a>
                </li>
                <li>
                  <a href={LINK_BLOG} target="_blank" rel="noreferrer noopener nofollow">
                    Blog <ArrowTopRightOnSquareIcon className="h-4 w-4" />
                  </a>
                </li>
                <li>
                  <a href={LINK_NOTION} target="_blank" rel="noreferrer noopener nofollow">
                    Notion <ArrowTopRightOnSquareIcon className="h-4 w-4" />
                  </a>
                </li>
              </ul>
            </li>
            <li>
              <Link href="/careers/resume" onClick={handleMenuClicked}>
                Resume
              </Link>
            </li>
            <li>
              <Link href="/careers/contact" onClick={handleMenuClicked}>
                Contact
              </Link>
            </li>
          </ul>
        </div>
      </MyPortal>
    </>
  )
}
export default ToggleMenu

Daisy UI Drawer 컴포넌트
tsx

Daisy UI 의 Drawer 컴포넌트를 React 버전으로 구현한 컴포넌트입니다.

typescript
react
react-portal
daisy ui
import { useCallback, useEffect, useState } from 'react'

const useHash = () => {
  const [hash, setHash] = useState<string>('')

  const hashChangeHandler = useCallback(() => {
    setHash(window.location.hash)
  }, [])

  useEffect(() => {
    setHash(window.location.hash)
  }, [])

  useEffect(() => {
    window.addEventListener('hashchange', hashChangeHandler)
    return () => {
      window.removeEventListener('hashchange', hashChangeHandler)
    }
  }, [hashChangeHandler])

  const updateHash = useCallback(
    (newHash: string) => {
      if (!newHash) {
        history.pushState(null, document.title, location.pathname + location.search)
        setHash('') // hash 를 아예 없애는 것은 HAshchange 트리거가 안됨
      } else if (newHash !== hash) {
        window.location.hash = newHash
      }
    },
    [hash]
  )

  return [hash, updateHash] satisfies [string, (newHash: string) => void]
}

export default useHash

브라우저의 hash를 React로 관리하는 custom hook
tsx

브라우저의 hash를 관리하기 위해서는 effect를 사용해야 합니다.

react
DOM API
custom hook