state-in-url
Version:
Store state in URL as in object, types and structure are preserved, with TS validation. Same API as React.useState, wthout any hasssle or boilerplate. Next.js@14-15, react-router@6-7, and remix@2.
770 lines (589 loc) • 25.1 kB
Markdown
[English](./README.md) | 한국어 | [简体中文](./README.CN.md)
<div align="center">
<img src="/packages/example-nextjs14/public/Logo_symbol.png" alt="state-in-url 로고" width="120px" />
# State in url
</div>
<div align="center">
</div>
<div align="center">
[](https://www.npmjs.com/package/state-in-url)

[](https://app.codacy.com/gh/asmyshlyaev177/state-in-url/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)

[](https://app.codacy.com/gh/asmyshlyaev177/state-in-url/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_coverage)
[](https://github.com/asmyshlyaev177/state-in-url/)
[]([https://github.com/semantic-release/semantic-release](https://github.com/asmyshlyaev177/state-in-url))
[](https://scorecard.dev/viewer/?uri=github.com/asmyshlyaev177/state-in-url)
[](https://www.bestpractices.dev/projects/9679)
[](https://github.com/asmyshlyaev177/state-in-url/blob/master/LICENSE)
</div>
<div align="center">
<h4 align="center">버그를 발견하거나 기능 요청이 있으시면 주저하지 말고 이슈를 열어주세요</h4>

# 데모
<a href="https://state-in-url.dev" target="_blank">데모</a> |
<a href="https://state-in-url.netlify.app/" target="_blank">미러 링크</a>
<a href="https://github.com/asmyshlyaev177/state-in-url/blob/master/Limits.md" target="_blank">URI 크기 제한, <b>최대 12KB</b>까지 안전</a>
<hr />
프로젝트를 지원하려면 <a href="#"><img src="https://raw.githubusercontent.com/acervenky/animated-github-badges/master/assets/starbadge.gif" width="25" height="25"></a>를 추가하고 <a href="https://github.com/asmyshlyaev177" target="_blank">팔로우</a>해주세요!
[토론](https://github.com/asmyshlyaev177/state-in-url/discussions/1)에서 여러분의 피드백/의견을 공유해주시면 감사하겠습니다
유용하다면 공유해주세요.
[X.com](https://twitter.com/intent/tweet?&url=https://github.com/asmyshlyaev177/state-in-url)
[LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https://github.com/asmyshlyaev177/state-in-url)
[FB](https://www.facebook.com/sharer.php?u=https://github.com/asmyshlyaev177/state-in-url)
[VK](http://vk.com/share.php?url=https://github.com/asmyshlyaev177/state-in-url)
<hr />
</div>
[바로 코드 보기!](#useurlstate)
## 왜 `state-in-url`을 사용해야 하나요?
쿼리 매개변수에 모든 사용자 상태를 저장하세요. 브라우저 URL에 JSON이 있다고 상상해보세요. 데이터의 타입과 구조를 유지하면서 모든 것을 저장할 수 있습니다. 예를 들어 숫자는 문자열이 아닌 숫자로, 날짜는 날짜로 디코딩되며, 객체와 배열도 지원됩니다.
매우 간단하고 빠르며 정적 Typescript 유효성 검사를 제공합니다. 딥 링크, 즉 URL 동기화가 쉬워집니다.
Next.js와 react-router용 `useUrlState` 훅과 그 외 JS 환경을 위한 헬퍼를 포함합니다.
최신 브라우저는 거대한 URL을 지원하고 사용자는 쿼리 문자열을 신경 쓰지 않습니다(전체 선택 후 복사/붙여넣기 워크플로우입니다).
쿼리 문자열을 원래 의도대로 상태 관리에 사용할 때입니다.
이 라이브러리는 모든 번거로운 작업을 대신 처리합니다.
이 라이브러리는 NUQS의 좋은 대안입니다.
### 사용 사례
- URL에 저장되지 않은 사용자 폼이나 페이지 필터 저장
- URL과 React 상태 동기화
- URI를 건드리지 않고 관련 없는 클라이언트 컴포넌트 간 데이터 동기화
- 애플리케이션 상태가 포함된 공유 가능한 URL (딥 링킹, URL 상태 동기화)
- 페이지 새로고침 시 상태 지속성 유지
### 특징
- 🧩 **간단함**: 프로바이더, 리듀서, 보일러플레이트 또는 새로운 개념 없음, `React.useState`와 유사한 API
- 📘 **Typescript 유효성 검사/자동완성**: 상태는 단순한 객체이며, Typescript 정의에 따라 IDE/테스트에서 자동 정적 유효성 검사
- ✨ **복잡한 데이터**: 중첩 객체, 날짜 및 배열, JSON과 동일하게 작동하지만 URL에서
- ☂ **기본값**: URL에 매개변수가 없으면 기본값 제공
- ⌨ **체계적**: 모든 가능한 값을 시작 시 정의하여 존재하지 alınandan 키를 가져오는 것을 방지
- **호환성**: 서드파티 쿼리 매개변수를 그대로 유지
- **유연성**: 동일한 페이지에서 1개 이상의 상태 객체 사용 가능, 다른 키만 사용하면 됨
- **빠름**: 최소한의 리렌더링, 큰 객체 인코딩 및 디코딩에 약 [1ms](https://github.com/asmyshlyaev177/state-in-url/blob/87c8c7c995c5cd7d9e7aa039c30bfe64b24abe4b/packages/urlstate/encoder/encoder.test.ts#L185) 소요
- **서버 사이드 렌더링**: 서버 컴포넌트에서 사용 가능, Next.js 14 및 15 지원
- **경량**: 의존성 없음, 라이브러리 크기 2KB 미만
- **개발자 경험**: 좋은 개발자 경험, 문서, JSDoc 주석 및 예제
- **프레임워크 유연성**: `Next.js` 및 `react-router`용 훅, 다른 프레임워크나 순수 JS와 함께 사용할 수 있는 헬퍼
- **충분한 테스트**: [Chrome/Firefox/Safari용 단위 테스트 및 Playwright 테스트](https://github.com/asmyshlyaev177/state-in-url/actions/workflows/tests.yml)
- **자유로운 라이선스**: MIT
## 목차
- [State in url](#state-in-url)
- [데모](#데모)
- [왜 `state-in-url`을 사용해야 하나요?](#왜-state-in-url을-사용해야-하나요)
- [사용 사례](#사용-사례)
- [특징](#특징)
- [목차](#목차)
- [설치](#설치)
- [1. 패키지 설치](#1-패키지-설치)
- [2. tsconfig.json 수정](#2-tsconfigjson-수정)
- [useUrlState](#useurlstate)
- [Next.js용 useUrlState 훅](#nextjs용-useurlstate-훅)
- [사용 예제](#사용-예제)
- [기본](#기본)
- [서버 사이드 렌더링과 함께](#서버-사이드-렌더링과-함께)
- [`layout` 컴포넌트에서 훅 사용](#layout-컴포넌트에서-훅-사용)
- [임의의 상태 형태 사용 (권장하지 않음)](#임의의-상태-형태-사용-권장하지-않음)
- [Remix.js용 useUrlState 훅](#remixjs용-useurlstate-훅)
- [예제](#예제)
- [React-Router용 useUrlState 훅](#react-router용-useurlstate-훅)
- [예제](#예제-1)
- [레시피](#레시피)
- [상태의 일부를 편리하게 다루기 위한 커스텀 훅](#상태의-일부를-편리하게-다루기-위한-커스텀-훅)
- [복잡한 상태 구조 사용](#복잡한-상태-구조-사용)
- [상태만 업데이트하고 URL에 수동으로 동기화](#상태만-업데이트하고-url에-수동으로-동기화)
- [기타 훅과 헬퍼](#기타-훅과-헬퍼)
- [다른 라우터용 `useUrlStateBase` 훅](#다른-라우터용-useurlstatebase-훅)
- [React.js용 `useSharedState` 훅](#reactjs용-usesharedstate-훅)
- [React.js용 `useUrlEncode` 훅](#reactjs용-useurlencode-훅)
- [`encodeState` 및 `decodeState` 헬퍼](#encodestate-및-decodestate-헬퍼)
- [`encode` 및 `decode` 헬퍼](#encode-및-decode-헬퍼)
- [모범 사례](#모범-사례)
- [주의사항](#주의사항)
- [기타](#기타)
- [기여 및/또는 로컬 실행](#기여-및또는-로컬-실행)
- [로드맵](#로드맵)
- [문의 및 지원](#문의-및-지원)
- [변경 로그](#변경-로그)
- [언급](#언급)
- [라이선스](#라이선스)
- [영감](#영감)
## 설치
### 1. 패키지 설치
```sh
# npm
npm install --save state-in-url
# yarn
yarn add state-in-url
# pnpm
pnpm add state-in-url
```
### 2. tsconfig.json 수정
`tsconfig.json`의 `compilerOptions`에서 `"moduleResolution": "Bundler"`, 또는 `"moduleResolution": "Node16"`, 또는 `"moduleResolution": "NodeNext"`로 설정하세요.
`"module": "ES2022"` 또는 `"module": "ESNext"` 설정이 필요할 수 있습니다.
## useUrlState
초기 상태를 매개변수로 받아 상태 객체, URL 업데이트 콜백, 상태만 업데이트하는 콜백을 반환하는 메인 훅입니다.
동일한 `state` 객체를 사용하는 모든 컴포넌트는 자동으로 동기화됩니다.
### Next.js용 useUrlState 훅
[전체 API 문서](packages/urlstate/next/useUrlState)
[React-Router 예제](#react-router용-useurlstate-훅)
#### 사용 예제
##### 기본
1. 기본값과 함께 상태 형태 정의
```typescript
// userState.ts
// 기본값과 다른 값을 가진 매개변수만 URL에 포함됩니다.
export const userState: UserState = { name: '', age: 0 }
// `Interface`가 아닌 `Type`을 사용하세요!
type UserState = { name: string, age: number }
```
2. 가져와서 사용
```typescript
'use client'
import { useUrlState } from 'state-in-url/next';
import { userState } from './userState';
function MyComponent() {
// `replace` 인자를 전달할 수 있으며, `setUrl`이 `router.push`를 사용할지 `router.replace`를 사용할지 제어합니다. 기본값 replace=true
// 서버 컴포넌트에서 `searchParams`를 전달할 수 있으며, 서버 컴포넌트에서 데이터를 가져와야 하는 경우 `useHistory: false`를 전달하세요
const { urlState, setUrl, setState } = useUrlState(userState);
return (
<div>
// urlState.name은 URL이 비어있으면 `userState`의 기본값을 반환합니다
<input value={urlState.name}
// React.useState와 동일한 API, 예: setUrl(currVal => currVal + 1)
onChange={(ev) => setUrl({ name: ev.target.value }) }
/>
<input value={urlState.age}
onChange={(ev) => setUrl({ age: +ev.target.value }) }
/>
<input value={urlState.name}
onChange={(ev) => { setState(curr => ({ ...curr, name: ev.target.value })) }}
// 상태를 즉시 업데이트하되 필요에 따라 URL에 동기화
onBlur={() => setUrl()}
/>
<button onClick={() => setUrl((_, initial) => initial)}>
초기화
</button>
</div>
)
}
```
##### 서버 사이드 렌더링과 함께
<details>
<Summary>예제</Summary>
```typescript
export default async function Home({ searchParams }: { searchParams: object }) {
return (
<Form searchParams={searchParams} />
)
}
// Form.tsx
'use client'
import React from 'react';
import { useUrlState } from 'state-in-url/next';
import { form } from './form';
const Form = ({ searchParams }: { searchParams: object }) => {
const { urlState, setState, setUrl } = useUrlState(form, { searchParams });
}
```
</details>
##### `layout` 컴포넌트에서 훅 사용
<details>
<Summary>예제</Summary>
이 부분은 까다로운데, app router를 사용하는 nextjs는 서버 측에서 searchParams에 접근할 수 없기 때문입니다. 미들웨어를 사용하는 해결 방법이 있지만 예쁘지 않고 nextjs 업데이트 후 작동하지 않을 수 있습니다.
```typescript
// 적절한 `layout.tsx`에 추가
export const runtime = 'edge';
// middleware.ts
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const url = request.url?.includes('_next') ? null : request.url;
const sp = url?.split?.('?')?.[1] || '';
const response = NextResponse.next();
if (url !== null) {
response.headers.set('searchParams', sp);
}
return response;
}
// 대상 레이아웃 컴포넌트
import { headers } from 'next/headers';
import { decodeState } from 'state-in-url/encodeState';
export default async function Layout({
children,
}: {
children: React.ReactNode;
}) {
const sp = headers().get('searchParams') || '';
return (
<div>
<Comp1 searchParams={decodeState(sp, stateShape)} />
{children}
</div>
);
}
```
</details>
##### 임의의 상태 형태 사용 (권장하지 않음)
<details>
<Summary>예제</Summary>
```typescript
'use client'
import { useUrlState } from 'state-in-url/next';
const someObj = {};
function SettingsComponent() {
const { urlState, setUrl, setState } = useUrlState<object>(someObj);
}
```
</details>
### Remix.js용 useUrlState 훅
API는 Next.js 버전과 동일하지만, [NavigateOptions](https://github.com/remix-run/react-router/blob/bc693ed9f39170bda13b9e1b282fb8e9d5925f66/packages/react-router/lib/context.ts#L99) 타입의 옵션을 전달할 수 있습니다.
[API 문서](packages/urlstate/remix/useUrlState)
#### 예제
```typescript
export const form: Form = {
name: '',
age: undefined,
agree_to_terms: false,
tags: [],
};
type Form = {
name: string;
age?: number;
agree_to_terms: boolean;
tags: { id: string; value: { text: string; time: Date } }[];
};
```
```typescript
import { useUrlState } from 'state-in-url/remix';
import { form } from './form';
function TagsComponent() {
const { urlState, setUrl, setState } = useUrlState(form);
const onChangeTags = React.useCallback(
(tag: (typeof tags)[number]) => {
setUrl((curr) => ({
...curr,
tags: curr.tags.find((t) => t.id === tag.id)
? curr.tags.filter((t) => t.id !== tag.id)
: curr.tags.concat(tag),
}));
},
[setUrl],
);
return (
<div>
<Field text="태그">
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Tag
active={!!urlState.tags.find((t) => t.id === tag.id)}
text={tag.value.text}
onClick={() => onChangeTags(tag)}
key={tag.id}
/>
))}
</div>
</Field>
<input value={urlState.name}
onChange={(ev) => { setState(curr => ({ ...curr, name: ev.target.value })) }}
// 상태를 즉시 업데이트하되 필요에 따라 URL에 동기화
onBlur={() => setUrl()}
/>
</div>
);
}
const tags = [
{
id: '1',
value: { text: 'React.js', time: new Date('2024-07-17T04:53:17.000Z') },
},
{
id: '2',
value: { text: 'Next.js', time: new Date('2024-07-18T04:53:17.000Z') },
},
{
id: '3',
value: { text: 'TailwindCSS', time: new Date('2024-07-19T04:53:17.000Z') },
},
];
```
[예제 코드](packages/example-remix2/app/routes/Form-for-test.tsx)
### React-Router용 useUrlState 훅
API는 Next.js 버전과 동일하지만, [NavigateOptions](https://github.com/remix-run/react-router/blob/bc693ed9f39170bda13b9e1b282fb8e9d5925f66/packages/react-router/lib/context.ts#L99) 타입의 옵션을 전달할 수 있습니다.
[API 문서](packages/urlstate/react-router/useUrlState)
#### 예제
```typescript
export const form: Form = {
name: '',
age: undefined,
agree_to_terms: false,
tags: [],
};
type Form = {
name: string;
age?: number;
agree_to_terms: boolean;
tags: { id: string; value: { text: string; time: Date } }[];
};
```
```typescript
import { useUrlState } from 'state-in-url/react-router';
// react-router v6의 경우
// import { useUrlState } from 'state-in-url/react-router6';
import { form } from './form';
function TagsComponent() {
const { urlState, setUrl, setState } = useUrlState(form);
const onChangeTags = React.useCallback(
(tag: (typeof tags)[number]) => {
setUrl((curr) => ({
...curr,
tags: curr.tags.find((t) => t.id === tag.id)
? curr.tags.filter((t) => t.id !== tag.id)
: curr.tags.concat(tag),
}));
},
[setUrl],
);
return (
<div>
<Field text="태그">
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Tag
active={!!urlState.tags.find((t) => t.id === tag.id)}
text={tag.value.text}
onClick={() => onChangeTags(tag)}
key={tag.id}
/>
))}
</div>
</Field>
<input value={urlState.name}
onChange={(ev) => { setState(curr => ({ ...curr, name: ev.target.value })) }}
// 상태를 즉시 업데이트하되 필요에 따라 URL에 동기화
onBlur={() => setUrl()}
/>
</div>
);
}
const tags = [
{
id: '1',
value: { text: 'React.js', time: new Date('2024-07-17T04:53:17.000Z') },
},
{
id: '2',
value: { text: 'Next.js', time: new Date('2024-07-18T04:53:17.000Z') },
},
{
id: '3',
value: { text: 'TailwindCSS', time: new Date('2024-07-19T04:53:17.000Z') },
},
];
```
[예제 코드](packages/example-react-router6/src/Form-for-test.tsx)
## 레시피
##### 상태의 일부를 편리하게 다루기 위한 커스텀 훅
<details>
<Summary>예제</Summary>
```typescript
'use client';
import React from 'react';
import { useUrlState } from 'state-in-url/next';
const form: Form = {
name: '',
age: undefined,
agree_to_terms: false,
tags: [],
};
type Form = {
name: string;
age?: number;
agree_to_terms: boolean;
tags: {id: string; value: {text: string; time: Date } }[];
};
export const useFormState = ({ searchParams }: { searchParams?: object }) => {
const { urlState, setUrl: setUrlBase, reset } = useUrlState(form, {
searchParams,
});
// 첫 번째 네비게이션은 새로운 히스토리 항목을 추가합니다
// 이후의 모든 것은 해당 항목을 교체합니다
// 이렇게 하면 2개의 항목만 있는 히스토리를 갖게 됩니다 - ['/url', '/url?key=param']
const replace = React.useRef(false);
const setUrl = React.useCallback((
state: Parameters<typeof setUrlBase>[0],
opts?: Parameters<typeof setUrlBase>[1]
) => {
setUrlBase(state, { replace: replace.current, ...opts });
replace.current = true;
}, [setUrlBase]);
return { urlState, setUrl, resetUrl: reset };
};
```
</details>
<hr />
##### 복잡한 상태 구조 사용
<details>
<Summary>예제</Summary>
```typescript
export const form: Form = {
name: '',
age: undefined,
agree_to_terms: false,
tags: [],
};
type Form = {
name: string;
age?: number;
agree_to_terms: boolean;
tags: { id: string; value: { text: string; time: Date } }[];
};
```
```typescript
'use client'
import { useUrlState } from 'state-in-url/next';
import { form } from './form';
function TagsComponent() {
// `urlState`는 Form 타입에서 추론됩니다!
const { urlState, setUrl } = useUrlState(form);
const onChangeTags = React.useCallback(
(tag: (typeof tags)[number]) => {
setUrl((curr) => ({
...curr,
tags: curr.tags.find((t) => t.id === tag.id)
? curr.tags.filter((t) => t.id !== tag.id)
: curr.tags.concat(tag),
}));
},
[setUrl],
);
return (
<div>
<Field text="태그">
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Tag
active={!!urlState.tags.find((t) => t.id === tag.id)}
text={tag.value.text}
onClick={() => onChangeTags(tag)}
key={tag.id}
/>
))}
</div>
</Field>
</div>
);
}
const tags = [
{
id: '1',
value: { text: 'React.js', time: new Date('2024-07-17T04:53:17.000Z') },
},
{
id: '2',
value: { text: 'Next.js', time: new Date('2024-07-18T04:53:17.000Z') },
},
{
id: '3',
value: { text: 'TailwindCSS', time: new Date('2024-07-19T04:53:17.000Z') },
},
];
```
[데모 페이지 예제 코드](https://github.com/asmyshlyaev177/state-in-url/blob/master/packages/example-nextjs14/src/app/Form.tsx)
</details>
##### 상태만 업데이트하고 URL에 수동으로 동기화
<details>
<Summary>예제</Summary>
```typescript
const timer = React.useRef(0 as unknown as NodeJS.Timeout);
React.useEffect(() => {
clearTimeout(timer.current);
timer.current = setTimeout(() => {
// 참조가 아닌 내용으로 상태를 비교하고 새 값에 대해서만 업데이트 실행
setUrl(urlState);
}, 500);
return () => {
clearTimeout(timer.current);
};
}, [urlState, setUrl]);
```
`onBlur`에서 상태를 동기화하는 것이 실제 사용과 더 일치합니다.
```typescript
<input onBlur={() => updateUrl()} .../>
```
</details>
## 기타 훅과 헬퍼
### 다른 라우터용 `useUrlStateBase` 훅
다른 라우터(예: react-router 또는 tanstack router)와 함께 자체 `useUrlState` 훅을 만들기 위한 훅입니다.
[API 문서](packages/urlstate/useUrlStateBase)
### React.js용 `useSharedState` 훅
모든 React 컴포넌트 간에 상태를 공유하는 훅으로, Next.js 및 Vite에서 테스트되었습니다.
```typescript
'use client'
import { useSharedState } from 'state-in-url';
export const someState = { name: '' };
function SettingsComponent() {
const { state, setState } = useSharedState(someState);
}
```
[API 문서](packages/urlstate/useSharedState/README.md)
### React.js용 `useUrlEncode` 훅
[API 문서](packages/urlstate/useUrlEncode/README.md)
### `encodeState` 및 `decodeState` 헬퍼
[API 문서](packages/urlstate/encodeState/README.md)
### `encode` 및 `decode` 헬퍼
[API 문서](packages/urlstate/encoder/README.md)
## 모범 사례
- 상태 형태를 상수로 정의하세요
- 향상된 타입 안전성과 자동완성을 위해 TypeScript를 사용하세요
- URL 매개변수에 민감한 정보(SSN, API 키 등)를 저장하지 마세요
- 읽기 쉬운 TS 오류를 위해 이 [확장 프로그램](https://marketplace.visualstudio.com/items?itemName=yoavbls.pretty-ts-errors)을 사용하세요
상태의 일부에 대한 상태 훅을 만들고 애플리케이션 전체에서 재사용할 수 있습니다. 예를 들어:
```Typescript
type UserState = {
name: string;
age: number;
other: { id: string, value: number }[]
};
const userState = {
name: '',
age: 0,
other: [],
};
export const useUserState = () => {
const { urlState, setUrl, reset } = useUrlState(userState);
// 기타 로직
// 다른 페이지로 이동할 때 쿼리 매개변수 초기화
React.useEffect(() => {
return reset
}, [])
return { userState: urlState, setUserState: setUrl };;
}
```
## 주의사항
1. 직렬화 가능한 값만 전달할 수 있습니다. `Function`, `BigInt` 또는 `Symbol`은 작동하지 않으며, `ArrayBuffer`와 같은 것도 마찬가지일 것입니다. JSON으로 직렬화할 수 있는 모든 것은 작동합니다.
2. Vercel 서버는 헤더(쿼리 문자열 및 기타 항목) 크기를 **14KB**로 제한하므로 URL 상태를 약 5000단어 미만으로 유지하세요. <https://vercel.com/docs/errors/URL_TOO_LONG>
3. app router를 사용하는 `next.js` 14/15로 테스트되었으며, pages를 지원할 계획은 없습니다.
## 기타
### 기여 및/또는 로컬 실행
[기여 문서](CONTRIBUTING.md)를 참조하세요
## 로드맵
- [x] `Next.js`용 훅
- [x] `react-router`용 훅
- [x] `remix`용 훅
- [ ] `svelte`용 훅
- [ ] `astro`용 훅
- [ ] 해시에 상태를 저장하는 훅?
## 문의 및 지원
- 버그 보고, 기능 요청 또는 질문을 위해 [GitHub 이슈](https://github.com/asmyshlyaev177/state-in-url/issues)를 생성하세요
## [변경 로그](CHANGELOG.md)
## 언급
- [This Week in React 209](https://thisweekinreact.com/newsletter/209)
- [JavaScript Weekly](https://javascriptweekly.com/issues/741)
- [This Week in React 240](https://thisweekinreact.com/newsletter/240)
## 라이선스
이 프로젝트는 [MIT 라이선스](LICENSE)에 따라 라이선스가 부여됩니다.
## 개인 웹사이트
[포트폴리오 사이트](https://asmyshlyaev177.dev)
## 영감
[NUQS](https://github.com/47ng/nuqs)
[Vue에서 URL을 사용하여 상태 저장](https://dev.to/jacobandrewsky/using-url-to-store-state-in-vue-275c)
[URL에 상태 저장하기](https://antonz.org/storing-state/)
[NextJS useSearchParams](https://nextjs.org/docs/app/api-reference/functions/use-search-params)