리액트 전역 상태 관리 라이브러리? ㄴㄴ 저는 URL만 씁니다

타락한스벨트전도사·2024년 9월 29일
86

제목이 자극적이 었나요? ㅎㅎ 그렇다면 성공입니다!

리액트로 개발하면 빼놓을 수 없는게 전역 상태 관리죠. 뭐 순수하게 UI컴포넌트를 만들어야 한다니 뭐니 해도, 저쪽에서 쓰고있는 상태를 이쪽에서 바꿔야 하는 일은 꼭 생깁니다.
그래서 이제 전역상태를 쓰고 싶은 욕구가 스멀스멀 나오죠. 근데 솔직히 이거때문에 redux를 쓰는건 좀.. 게다가 recoil, justand, valtio, 오우 쉣.. 아싸리 context api를 써? 리랜더링 최적화는? ㅠ 그냥 뇌정지 선택장애가 옵니다.

그래서 걍 url에 다 때려박기로 함 ㅎ..
근데 이거 약팔이가 아닙니다. 누가 상남자의 db는 url이라는데.. 진짜 url을 그냥 로컬 스토리지마냥 써버리면 다른 컴포넌트들 사이에서도 상태가 공유되겠죵? 게다가 새로고침 해도 상태 유지되니까 개발할 때 짱편합니다. 그리고 유저도 좋아함. 거의 일석이조, 일석삼조급의 url 상태 관리 전략!

이걸 위해서 useQueryParams 훅을 만들었습니다. 맨 아래에 전문 코드 담았으니 그대로 복붙해서 써가세용. (라이브러리로 만들기는 너무 하찮네요..)

자, 이제 URL이 어떻게 상태 관리의 대안이 될 수 있는지 함께 알아보시죠.


URL과 상태의 마법: 어떻게 동작하는 걸까요? 🎩✨

이 방식의 핵심을 간단한 도식으로 표현해볼게요.

+-------------------+       on mount           +-------------------+
|                   | -----------------------> |                   |
|    brower URL     |                          |    react state    |
|                   |                          |  (useQueryParams) |
|                   | <----------------------- |                   |
+-------------------+       sync by hook       +-------------------+

동작 방식을 자세히 살펴볼까요?

  1. 마운트 시 전달 (브라우저 URL → 리액트 상태):

    • 페이지가 처음 로드될 때, useQueryParams 훅이 URL의 쿼리 파라미터를 읽어옵니다.
    • 이 쿼리 파라미터들이 리액트 상태로 변환됩니다.
    • 예: ?page=2&search=react{ page: 2, search: "react" }
  2. hook으로 동기화 (리액트 상태 → 브라우저 URL):

    • 컴포넌트에서 상태를 변경하면, useQueryParams 훅이 이를 감지합니다.
    • 변경된 상태가 자동으로 URL의 쿼리 파라미터로 반영됩니다.
    • 예: setParams({ page: 3 }) → URL이 ?page=3&search=react로 업데이트

리액트가 싫어하는 양방향을 어떻게든 잘 핸들링하면서 풀어내면서 URL과 리액트 상태가 항상 동기화됩니다.
어쨌거나 URL이 바뀌면 상태가 바뀌고, 상태가 바뀌면 URL이 바뀌니 ok입니다!

잠깐!! URL로 상태 관리하면 이런 점이 좋습니다 🚀

  1. 새로고침 해도 안전해요 - 페이지를 새로고침해도 상태가 그대로 유지됩니다. 개발할 때 정말 편해요!

  2. 링크 공유가 쉬워집니다 - 현재 상태가 포함된 URL을 그대로 공유할 수 있어요. 협업할 때 아주 유용하죠.

  3. 북마크가 가능해요 - 특정 상태의 페이지를 북마크해둘 수 있어요. 나중에 그 상태로 바로 돌아올 수 있죠.

  4. 브라우저 히스토리와 완벽 호환 - 뒤로 가기, 앞으로 가기 버튼이 자연스럽게 작동합니다. UX가 확 좋아져요.

  5. 서버사이드 렌더링(SSR)과 궁합이 좋아요 - 초기 상태를 서버에서 쉽게 파악할 수 있어 SSR이 더 간단해집니다.

  6. 별도의 라이브러리가 필요 없어요 - 추가적인 의존성 없이 순수한 리액트만으로 구현할 수 있어요.

  7. 디버깅이 쉬워져요 - 상태가 URL에 직접 노출되어 있어 디버깅하기가 훨씬 수월해집니다.

useQueryParams 훅 사용하기: 실전 예제 🚀

자, 이제 이 마법 같은 useQueryParams 훅을 실제로 어떻게 사용하는지 살펴볼까요? 간단한 검색 기능이 있는 페이지를 예로 들어보겠습니다.

import React from 'react';
import { useQueryParams } from './useQueryParams';

function SearchPage() {
  const [{ query = '', page = 1 }, setParams] = useQueryParams({
    query: 'string',
    page: 'number'
  });

  const handleSearch = (event) => {
    event.preventDefault();
    setParams({ query: event.target.search.value, page: 1 });
  };

  const handleNextPage = () => {
    setParams({ page: page + 1 });
  };

  return (
    <div>
      <form onSubmit={handleSearch}>
        <input 
          name="search" 
          defaultValue={query} 
          placeholder="검색어를 입력하세요"
        />
        <button type="submit">검색</button>
      </form>
      
      <div>
        검색어: {query}, 페이지: {page}
      </div>
      
      <button onClick={handleNextPage}>다음 페이지</button>
    </div>
  );
}

이 예제의 장점이 보이시나요?

  • URL이 https://yourapp.com/search?query=react&page=2 형태로 자동 업데이트됩니다.
  • 이 URL을 공유하면, 받는 사람도 동일한 검색 결과와 페이지를 볼 수 있습니다.
  • 페이지를 새로고침해도 검색어와 페이지 번호가 유지됩니다.
  • 브라우저의 뒤로 가기, 앞으로 가기 버튼이 자연스럽게 작동합니다.
  • 이미 많은 웹이나 협업툴들이 저렇게 url에 상태를 넣고 있다는 사실 알고 있었나요?

그리고 이 모든 것이 우리가 특별히 추가 로직을 작성하지 않아도 자동으로 이루어집니다!
useQueryParams 훅이 모든 복잡한 작업을 대신 처리해주니까요.

useQueryParams의 숨은 비밀: 이런 점들을 고려했습니다 🕵️‍♂️

useQueryParams를 만들면서 단순히 "동작하는" 코드를 넘어, 정말 "좋은" 코드를 만들기 위해 몇 가지 특별한 점에 신경을 썼습니다. 제가 고민한 부분들을 살짝 공유해볼게요.

1. 선택적 렌더링: 불필요한 리렌더링? 그건 용납할 수 없죠 🚫

React에서 불필요한 리렌더링은 성능의 적입니다. useQueryParams를 만들면서 가장 신경 쓴 부분이 바로 이거예요.

const [params, setParams] = useQueryParams({
  page: 'number',
  search: 'string'
});

여기서 쓰고 있는건 pagesearch 두개의 파라미터가 있는데요. 다른 곳에서 저 2개 이외의 다른 쿼리 파라미터를 바꿔도 저 컴포넌트는 리랜더링이 되지 않습니다. 이를 위해 내부적으로 각 파라미터의 변경을 개별적으로 추적하는 로직을 구현했어요. 덕분에 필요한 부분만 정확하게 업데이트됩니다.

Next.js의 useSearchParamsusePathname을 사용했다면 이런 세밀한 제어가 불가능했을 거예요. 그들은 URL이 조금이라도 변경되면 무조건 리렌더링을 일으키거든요. 하지만 우리의 useQueryParams는 다르답니다. 😎

2. 타입 추론: TypeScript의 힘을 120% 활용했습니다 💪

타입스크립트를 제대로 활용하지 않는다면, 그건 자바스크립트를 쓰는 것과 다를 바 없죠. useQueryParams는 타입 추론의 극치를 보여줍니다.

const [params, setParams] = useQueryParams({
  page: 'number',
  search: 'string'
});

// 이 시점에서 IDE가 알려주는 타입은?
// params: { page?: number; search?: string }
// setParams: (newParams: Partial<{ page?: number; search?: string }>) => void

보이시나요? 파라미터의 타입을 자동으로 추론해주고, setParams 함수의 인자 타입까지 정확하게 지정해줍니다. 이건 단순한 타입 지정이 아니라, 고급 타입스크립트 기술의 결정체예요.

3. Next.js와의 찰떡 호환: SSR도 문제없어요 🔄

서버사이드에서 query string을 받기 위해, usePathname쓰면 선택적 리랜더링을 구현한 보람이 사라지겠죠? 그래서 제가 그냥 nextjs 코드 뒤져가가지고 발견했습니다. 저 hook이 내부적으로는 아래 모듈을 쓰고 있더라구요.

import { staticGenerationAsyncStorage } from "next/dist/client/components/static-generation-async-storage.external";

function getSearchParams() {
  return new URLSearchParams(getSearchString())
}

function getSearchString(): string {
  const isBrouswer = typeof window !== 'undefined'

  if (!isBrouswer) {
    const store = staticGenerationAsyncStorage.getStore()
    const pathname = store?.urlPathname
    if (pathname == null) return ''
    const q = pathname.match(RegExp(/[?w+]/))?.index
    if (q == null) return ''


    return pathname.substring(q)
  }

  return window.location.search
}

이렇게 Next.js의 내부 동작을 깊이 이해하고 활용함으로써, SSR 환경에서도 완벽하게 동작하는 훅을 만들어냈습니다. 클라이언트 사이드에서는 물론이고, 서버 사이드에서도 URL을 정확하게 파악하고 처리할 수 있죠.

마치며: 코드 전문 공유드립니다. 🌟

뭐 사실 리랜더링 그렇게 신경 안써도 된다지만, 그래도 코드 몇줄만 신경쓰면 리랜더링 안하고, url 상태 관리 할 수 있으니까 좋죠. 아래는 제가 작성한 코드입니다!

import { useEffect, useState, useCallback } from 'react';

type QueryparamTypeMap = {
  'string': string
  'boolean': boolean
  'number': number
}

type QueryParamType = keyof QueryparamTypeMap

type QueryparamConfig = {
  [key in string]: QueryParamType
}

type QueryParamResult<T extends QueryparamConfig> = {
  [key in keyof T]?: QueryparamTypeMap[T[key]]
}

const v = {
  parse(
    value: string | null,
    type: QueryParamType
  ) {
    if (value === null) return undefined;
    switch (type) {
      case 'string':
        return value;
      case 'number':
        return Number(value);
      case 'boolean':
        return value === 'true';
    }

  },
  serialize(value: any, type: QueryParamType) {
    if (value === undefined || value === null) return undefined;
    switch (type) {
      case 'string':
        return String(value);
      case 'number':
        return String(value);
      case 'boolean':
        return value ? 'true' : 'false';
    }
  }
}

export function useQueryParams<T extends QueryparamConfig>(
  config: T, deps: any[] = []
): [QueryParamResult<T>, (newParams: Partial<QueryParamResult<T>>) => void] {

  const getParams = useCallback(() => {
    const searchParams = getSearchParams()
    const newParams = {} as QueryParamResult<T>;
    for (const key in config) {
      const type = config[key];
      const value = v.parse(searchParams.get(key), type);
      newParams[key] = value as any
    }
    return newParams;
  }, deps)

  const [params, _setParams] = useState<QueryParamResult<T>>(() => {
    return getParams()
  });

  const setParams = useCallback((newParams: QueryParamResult<T>) => {
    _setParams((params) => {
      return isEqual(params, newParams) ? params : { ...params, ...newParams }
    })
  }, [])


  const syncParams = useCallback((newParams: Partial<QueryParamResult<T>>) => {
    if (typeof window === 'undefined') return;

    const searchParams = getSearchParams()
    for (const key in newParams) {
      const type = config[key];
      const value = v.serialize(newParams[key], type);
      if (value != null) {
        searchParams.set(key, value);
      } else {
        searchParams.delete(key);
      }
    }
    const newUrl =
      window.location.pathname +
      '?' +
      searchParams.toString() +
      window.location.hash;

    pushState(newUrl)

  }, deps);

  useEffect(() => {
    const handleStateChange = () => {
      const newParams = getParams()
      setParams(newParams);
    };

    window.addEventListener('popstate', handleStateChange);
    pushStateEventManager.addEventListener(handleStateChange)

    return () => {
      window.removeEventListener('popstate', handleStateChange);
      pushStateEventManager.removeEventListener(handleStateChange)
    }
  }, deps);

  return [params, syncParams];
}


const pushStateEventManager = function () {
  let subscribers: (Function)[] = []

  return {
    notify: () => {
      subscribers.forEach((callback) => {
        callback()
      })
    },
    addEventListener: (callback: Function) => {
      subscribers.push(callback)
    },
    removeEventListener: (callback: Function) => {
      subscribers = subscribers.filter((v) => callback !== v)
    }
  }
}()

export function pushState(newUrl: string) {
  window.history.pushState({}, '', newUrl)
  pushStateEventManager.notify()
}

import { staticGenerationAsyncStorage } from "next/dist/client/components/static-generation-async-storage.external";

function getSearchParams() {
  return new URLSearchParams(getSearchString())
}

function getSearchString(): string {
  const isBrouswer = typeof window !== 'undefined'

  if (!isBrouswer) {
    const store = staticGenerationAsyncStorage.getStore()
    const pathname = store?.urlPathname
    if (pathname == null) return ''
    const q = pathname.match(RegExp(/[?w+]/))?.index
    if (q == null) return ''


    return pathname.substring(q)
  }

  return window.location.search
}

function isEqual(a: any, b: any): boolean {
  if (a === b) return true
  if (typeof a !== typeof b) return false

  if (typeof a === 'object') {
    const aEntries = Object.entries(a)
    const bEntries = Object.entries(b)

    if (aEntries.length !== bEntries.length) return false

    return aEntries.every(([key, value]) => b[key as any] === value)
  }

  return false
}
profile
스벨트쓰고요. 오픈소스 운영합니다

12개의 댓글

comment-user-thumbnail
2024년 10월 2일

안녕하세요! 글 잘보았습니다 :)

두가지 정도 질문이있습니다.

첫번째)
실무에서 url를 사용하여 상태관리를 할때에는 string 타입에서 더욱 타입을 좁히고 싶은 순가이 오더라고요
'admin' | 'user' 이런식으로요.

QueryparamTypeMap을 type merging할수있는 방법으로 키워볼수있을것같은데 어떻게 생각하시는지 궁금하네요.

두번째)
아래에 Ryomi님꼐서 질문주신내용과 비슷한 내용입니다만 url 문자열은 브라우저 정책마다 조금씩 상이하나 최대길이가 있어 한정적인것같습니다
혹시 생각해두신 쿼리파라미터 최적화 방법이 있으실까요?

1개의 답글
comment-user-thumbnail
2024년 10월 3일

👍👍👍

1개의 답글
comment-user-thumbnail
2024년 10월 10일

useQueryPrams 오픈소스화 안하시나요

1개의 답글
comment-user-thumbnail
2024년 10월 11일

잘 읽었습니다! isBrouswer -> isBrowser 오타가 있는거 같아요

1개의 답글
comment-user-thumbnail
3일 전

국제화를 하는 경우 nextIntlRouter를 사용해야하는데 이런 경우에는 어떻게 처리하는게 좋을까요? : )

1개의 답글