우아한 테크코스 프리코스를 해보며 처음 도입을 해봤던 eslint 와 prettier의 조합은 일관성 있는 코딩컨벤션을 지킬 수 있었기에 그 이후 협업을 하거나 일관된 코드를 작성하고 싶어서 eslint 와 prettier 를 도입하게 되었다.
또한 react 를 사용하며 다른 컴포넌트나 react hook, theme, img, 라이브러리 등을 import 해서 사용할 때가 많은데 훅은 위로, theme 는 아래로 배치하는 것과 같이 import 하는 것들을 정리해주려는 목적도 있다.
또한 이것을 설정하면서 절대경로도 도입하게 되는데 상대경로로 import 하다보면 한도 없이 길어지는 경로를 간단하게 줄여서 표현할 수 있기에 프로젝트 초반에 항상 이 설정을 하고 시작하는 편이다.
ESLint
: 자바스크립트 코드를 정적 분석해 잠재적인 문제를 발견하고 나아가 수정까지 도와주는 도구
ESLint 가 사용하는 파서의 기본값은
espree
이다. 코드를espree
로 분석하면 JSON 형태로 구조화된 결과를 얻을 수 있다.
espree
: 코드 분석 도구.
- 변수 / 함수 / 함수명이 무엇인지 / 코드의 정확한 위치 같은 세세한 정보도 분석해 알려준다.
@typescript-eslint/typescript-estree
라는 espree 기반 파서가 있고 이를 통해 타입스크립트 코드를 분석해 구조화한다.
ESLint 규칙
: ESLint 가 espree 로 코드를 분석한 결과를 바탕으로 어떤 코드가 잘못된 코드고 어떻게 수정해야 할지 정하는 규칙
plugin
: 특정한 ESLint 규칙의 모음
ESLint 규칙들을 모아놓은 패키지다.
리액트, import 같이 특정 프레임워크나 도메인과 관려된 규칙을 묶어서 제공하는 패키지
eslint-plugin-import 패키지
eslint-plugin-react 패키지
eslint-plugin
을 한데 묶어서 완벽하게 한 세트로 제공하는 패키지
- eslint-plugin, eslint-config 라는 접두사를 쓸 경우 뒤에 오는 단어는 반드시 한 단어로 구성해야 한다.
ex) eslint-config-naver ( O ) / eslint-config-naver-financials ( X )- 특정 스코프가 앞에 붙는 것은 가능하다.
ex) @titicaca/eslint-config-triple ( O ) / @titicaca/eslint-config-triple-rules ( X )
eslint-config-airbnb
eslint-config
eslint-config-google
, eslint-config-naver
대비 압도적인 다운로드 수를 보인다.@titicaca/triple-config-kit
eslint-config
가 eslint-config-airbnb
를 기반으로 약간 룰을 수정해 배포하고 있으나 이 패키지는 자체적으로 정의한 규칙을 기반으로 운영@titicaca/prettier-config-triple
, @titicaca/stylelint-config-triple
로 모노레포를 만들어 관리중이라 각각 필요에 따라 설치해 사용가능eslint-config-next
eslint-config
트리쉐이킹
: 번들러가 코드 어디에서도 사용하지 않는 코드 (dead code) 를 삭제해서 최종 번들 크기를 줄이는 과정을 의미한다. 나무의 나뭇잎을 털어낸다는 의미에서 유래했다.
만약 일부 코드에서 특정 규칙을 임시로 제외시키고 싶다면 eslint-disable- 주석을 사용하면 된다. 특정 줄만 제외하거나 파일 전체를 제외하거나 특정 범위에 걸쳐 제외하는 것이 가능하다.
// 특정 줄만 제외
console.log('hello world') // eslint-disable-line no-console
// 다음 줄 제외
// eslint=disable-next-line no-console
console.log('hello world')
// 특정 여러 줄 제외
/* eslint-disable no-console */
console.log('hello world')
console.log('hello world')
/* eslint-disable no-console */
// 파일 전체에서 제외
/* eslint-disable no-console */
console.log('hello world')
eslint-disable-line no-exhaustive-deps
npm i -D eslint prettier eslint-config-prettier eslint-plugi
n-prettier
eslint-config-prettier
: prettier 를 eslint plugin 으로 추가하며 prettier 가 인식하는 코드 포맷 오류를 eslint 오류로 출력하도록 한다.eslint-plugin-prettier
: eslint 의 코드 포맷과 관련된 rule 중 prettier 와 충돌하는 부분을 비활성화할 수 있다.{
"singleQuote": true,
"semi": false,
"printWidth": 80
}
{
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "prettier", "import"],
"extends": [
"eslint:recommended",
"airbnb",
"prettier",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"linebreak-style": 0,
"import/prefer-default-export": 0,
"prettier/prettier": 0,
"import/extensions": 0,
"no-use-before-define": 0,
"import/no-unresolved": 0,
"import/no-extraneous-dependencies": 0,
"no-shadow": 0,
"react/prop-types": 0,
"react/react-in-jsx-scope": "off",
"react/function-component-definition": [
2,
{
"namedComponents": "function-declaration",
"unnamedComponents": "arrow-function"
}
],
"react/jsx-filename-extension": [
2,
{ "extensions": [".js", ".jsx", ".ts", ".tsx"] }
],
"jsx-a11y/no-noninteractive-element-interactions": 0,
"react/jsx-props-no-spreading": "warn",
"jsx-a11y/label-has-associated-control": [
2,
{
"labelAttributes": ["htmlFor"]
}
],
"import/order": [
"warn",
{
"groups": ["builtin", "external", ["parent", "sibling"], "index"],
"newlines-between": "always",
"alphabetize": { "order": "asc" }
}
]
}
}
groups 에 들어가는 세부 사항들에 대해 정리해봤다.
groups 엔 ["builtin", "external", "internal", "parent", "sibling", "index", "object", "type"]
가 올 수 있고 아래에 예시를 찾아두었다.
// 1. node "builtin" modules
import fs from 'fs';
import path from 'path';
// 2. "external" modules
import _ from 'lodash';
import chalk from 'chalk';
// 3. "internal" modules
// (if you have configured your path or webpack to handle your internal paths differently)
import foo from 'src/foo';
// 4. modules from a "parent" directory
import foo from '../foo';
import qux from '../../foo/qux';
// 5. "sibling" modules from the same or a sibling's directory
import bar from './bar';
import baz from './bar/baz';
// 6. "index" of the current directory
import main from './';
// 7. "object"-imports (only available in TypeScript)
import log = console.log;
// 8. "type" imports (only available in Flow and TypeScript)
import type { Foo } from 'foo';
groups 내에 명시하지 않은 모듈은 모두 동일한 순서를 지닌다.
builtin
: 내장 모듈external
: 외부 라이브러리 모듈internal
: 내부 라이브러리 모듈, tsconfigparent
: 상대경로중 상위 디렉토리sibling
: 상대경로중 동일 디렉토리 혹은 하위 디렉토리index
: 현재 디렉토리object
: 객체 -> TypeScript 에서만 가능type
: 타입 -> TypeScript, Flow 에서 가능newlines-between : always
: 다른 그룹 사이에 한줄 띄고 같은 그룹 내에서는 사이에 빈 줄을 허용하지 않는다.
alphabetize : { order: asc }
: 알파벳 오름차순 기준으로 정렬한다.
스타일을 적용하는 다양한 방식중에서 styled-components 를 가장 애용중인데 bootstrap 이나 tailwind css 같은 경우 class 가 점점 길어지는 것이 가독성에 좋지 않다고 생각되어 보통 styled-components 를 활용하는 방식을 주로 사용하고 있다.
npm i styled-components
npm i -D @types/styled-components
styled-components 의 createGlobalStyle 로 reset css 를 적용했고 그 외 테마는 DefaultTheme 를 설정해서 적용했다.
// GlobalStyle.tsx
import { createGlobalStyle } from "styled-components"
const GlobalStyle = createGlobalStyle`
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
background-color: #F0EBF8;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
`
export default GlobalStyle
// defaultTheme.ts
import { DefaultTheme } from "styled-components"
const defaultTheme: DefaultTheme = {
colors: {
WHITE: "#FFFFFF",
PURPLE_LIGHT: "#F0EBF8",
PURPLE_DARK: "#673AB7",
FOCUS_BLUE: "#4285F4",
GRAY: "#202124",
},
}
export default defaultTheme
react + redux 만으로 웹 페이지를 구성하려니 파일 시스템 구조를 따르는 next.js 와 달리 각 페이지별 라우팅을 따로 설정해줘야 한다. 이를 위해 react-router-dom 을 사용하려고 한다.
React Router 에는 두가지 방식의 라우터 사용방법이 있다.
BrowserRouter
import * as React from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
const root = createRoot(document.getElementById("root"));
root.render(
<BrowserRouter>
{/* The rest of your app goes here */}
</BrowserRouter>
);
//////
function App() {
return (
<BrowserRouter basename="/app">
<Routes>
<Route path="/" /> {/* 👈 Renders at /app/ */}
</Routes>
</BrowserRouter>
);
}
BrowserRouter 는 clean URL 을 사용해서 브라우저 주소바의 현재 위치를 저장한다. 그리고 브라우저의 built-in history stack 을 사용해서 navigate 할 수 있게 한다.
createBrowserRouter
React Router 는 createBrowserRouter 를 모든 React Router 웹 프로젝트에서 사용할 것을 추천하고 있다. 이는 URL 을 업데이트하고 history stack 을 관리하기 위해 DOM History API 를 사용한다.
또한 이를 사용해야 loader, action, fetcher 같은 v6.4 에서 새로 추가된 data API 를 사용할 수 있다.
두가지 방식중 어떤 방식을 사용할지 고민을 해본 결과 createBrowserRouter 를 사용하기로 했다. v5 까지는 BrowserRouter 를 주로 사용했으나 현재 v6 을 넘어 업데이트가 되고 있는 상황이기도 하고 React Router 에서 권하는 createBrowserRouter 를 쓰지 않을 이유가 없다고 생각이 들었다.
새 방식을 적용하는데 거부감이 적은 편이니 바로 사용해봤다.
createBrowserRouter 를 사용중이니 action 이나 loader 같은 data API 를 활용할 수 있다.
React Router 문서를 찾다보면 useNavigate 페이지에서 이것을 사용하지 말고 action 이나 loader 등을 통해 redirect 를 하라는 것을 권장하고 있다.
이 이유에 대해서 React Router 에 대해 찾아보며 정리한 페이지에 작성해두었다.
그러니 redirect 를 써야 할 것으로 생각되나 이 프로젝트는 구글 폼이 동작하는 것처럼 보이도록 하고 실제 데이터는 전역상태관리 라이브러리인 redux 에 저장해둘 생각이라 실제 data fetch 가 이루어지지 않을 것이다. (백엔드를 만들 생각이 없으니..)
그렇다면 그냥 useNavigate 를 쓰는 것이 맞나.. 하는 고민이 든다.
왜냐하면 loader 는 앱이 렌더되기 전에 실행된다면 action 은 특정 동작에 의해 수행된다. 이때 action 은 get 을 제외한 post, put, patch, delete 와 같이 non-get submission 을 요청할때 호출된다.
즉, 내가 원하는 것은 제출버튼을 클릭하는 동작 / 눈 모양을 클릭하는 동작등을 통해 다른 페이지로 navigate 를 해주고 싶은데 그 과정에서 HTTP 요청이 없을 것이다. 그러니 loader 나 action 을 사용하게 될까 하는 생각이 들어 일단 useNavigate 를 사용하려 한다.
npm i react-router-dom
npm i -D @types/react-router-dom
import React from "react"
import ReactDOM from "react-dom/client"
import { Provider } from "react-redux"
import { RouterProvider, createBrowserRouter } from "react-router-dom"
import { ThemeProvider } from "styled-components"
import Root from "./Root"
import { store } from "./app/store"
import { Form } from "./features/form/Form"
import { Preview } from "./features/preview/Preview"
import { Result } from "./features/result/Result"
import GlobalStyle from "./style/GlobalStyle.js"
import defaultTheme from "./style/defaultTheme.js"
import "./index.css"
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
children: [
{
path: "form",
element: <Form />,
},
{
path: "preview",
element: <Preview />,
},
{
path: "result",
element: <Result />,
},
],
},
])
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ThemeProvider theme={defaultTheme}>
<Provider store={store}>
<GlobalStyle />
<RouterProvider router={router} />
</Provider>
</ThemeProvider>
</React.StrictMode>,
)
BrowserRouter 와 달리 App 을 사용하지 않고 RouterProvider 를 사용하며 children 을 사용할 경우 parent 에 Outlet 컴포넌트를 사용해 children 이 부모 컴포넌트중 어디에 들어가야 하는지 표시해야 한다.
RouterProvider
: 모든 데이터 라우터 객체는 이 컴포넌트로 전달해야 앱을 렌더링할 수 있고 나머지 데이터 API 를 사용할 수 있다.createBrowserRouter 내에 들어가는 router 정보를 따로 파일로 분리하려 했는데 위와 같은 ts2749 에러가 발생했다.
찾다보니 이런 에러는 .tsx 로 파일을 만들어야 하는데 .ts 로 파일을 생성했을 경우 뜨는 에러라는 것을 알게 되었다.
.ts 와 .tsx 의 차이에 대해 자세히 알지 못하고 그저 컴포넌트를 사용할 때 tsx 같이 사용하는게 관례.. 라고 알고 있었는데 이번 기회를 삼아 차이에 대해 찾아봤다.
- .ts : typescript 만 사용할 경우
- .tsx : react component 와 같이 사용할 경우에 사용
즉, 위의 경우 Root, Form, Preview, Result 같은 컴포넌트를 타입스크립트에 같이 사용하려 하는데 파일 확장자가 .ts 라서 에러가 뜬 것이었다.
일단 시간이 굉장히 부족하다. 만들어져 있는 컴포넌트를 쉽게 가져다 쓸 수 있다면 기꺼이 가져다 쓸 생각이다. AntD 와 MUI 를 모두 사용해본 결과 Docs 는 MUI 가 더 잘되어 있는 것 같아 MUI 를 사용하려고 한다.
npm install @mui/icons-material @mui/material @emotion/styled @emotion/react
MUI 사이트에 나온 코드를 그대로 import 해서 쓰면 된다.
import * as React from 'react';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
export default function Color() {
return (
<Typography component="div" variant="body1">
<Box sx={{ color: 'primary.main' }}>primary.main</Box>
<Box sx={{ color: 'secondary.main' }}>secondary.main</Box>
<Box sx={{ color: 'error.main' }}>error.main</Box>
<Box sx={{ color: 'warning.main' }}>warning.main</Box>
<Box sx={{ color: 'info.main' }}>info.main</Box>
<Box sx={{ color: 'success.main' }}>success.main</Box>
<Box sx={{ color: 'text.primary' }}>text.primary</Box>
<Box sx={{ color: 'text.secondary' }}>text.secondary</Box>
<Box sx={{ color: 'text.disabled' }}>text.disabled</Box>
</Typography>
);
}
import * as React from 'react';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
export default function FontWeight() {
return (
<Typography component="div">
<Box sx={{ fontWeight: 'light', m: 1 }}>Light</Box>
<Box sx={{ fontWeight: 'regular', m: 1 }}>Regular</Box>
<Box sx={{ fontWeight: 'medium', m: 1 }}>Medium</Box>
<Box sx={{ fontWeight: 500, m: 1 }}>500</Box>
<Box sx={{ fontWeight: 'bold', m: 1 }}>Bold</Box>
</Typography>
);
}
import * as React from 'react';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
export default function LineHeight() {
return (
<Typography component="div">
<Box sx={{ lineHeight: 'normal', m: 1 }}>Normal height.</Box>
<Box sx={{ lineHeight: 2, m: 1 }}>line-height: 2</Box>
</Typography>
);
}
control-has-associated-label 은 button 내에 text label 이 있어야 한다는 규칙을 명시하는 패키지다.
그런데 나같이 내부에 텍스트를 넣기 싫고 아이콘을 넣고자 할 경우 텍스트 대신 aria-label attribute 를 줘서 대체할 수 있다.
recoil 을 사용할 때 새로고침하면 상태가 날아가는 현상이 있었다. 서버에서 hydration 하기전 html 을 만들어서 클라이언트로 보냈는데 클라이언트가 서버로부터 데이터를 다시 받지 않고 새로고침만 해서 상태값이 날라간 것이었다.
이때 이 문제를 해결하기 위해 recoil-persist 를 사용했는데 redux 역시 redux-persist 가 있어서 필요할 것이라 생각했다.
새로고침하거나 앱을 종료해도 store 가 리셋되는 것을 방지하기 위해 사용했다.
redux-persist 를 사용시 localstorage 와 sessionstorage 에 상태를 저장하게 해준다.
- localStorage 에 store 를 저장하고 싶을때
import storage from 'redux-persist/lib/storage'
- sessionStorage 에 store 를 저장하고 싶을때
import storageSession from 'redux-persist/lib/storage/session'
이번엔 localStorage 에 store 를 저장하고자 storage 를 import 했다.
npm i redux-persist
@types/redux-persist 는 deprecated 되었으니 타입스크립트를 사용중이라 해서 따로 설치할 필요 없다.
// store.ts
const persistConfig = {
key: "root",
storage,
whitelsit: ["cards"], // 오직 cards 만 persisted 되도록 설정
}
const persistedReducer = persistReducer(persistConfig, cardsSlice.reducer)
export const store = configureStore({
reducer: {
cards: persistedReducer,
},
})
persistConfig 와 persistReducer 를 생성한다.
//main.tsx
import React from "react"
import ReactDOM from "react-dom/client"
import { Provider } from "react-redux"
import { RouterProvider, createBrowserRouter } from "react-router-dom"
import { persistStore } from "redux-persist"
import { PersistGate } from "redux-persist/integration/react"
import { ThemeProvider } from "styled-components"
import Root from "./Root"
import { store } from "./app/store"
import { Form } from "./features/form/Form"
import { Preview } from "./features/preview/Preview"
import { Result } from "./features/result/Result"
import { RouterInfo } from "./routes/RouterInfo.js"
import GlobalStyle from "./style/GlobalStyle.js"
import defaultTheme from "./style/defaultTheme.js"
import "./index.css"
const router = createBrowserRouter(RouterInfo)
// eslint-disable-next-line prefer-const
let persistor = persistStore(store)
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ThemeProvider theme={defaultTheme}>
<Provider store={store}>
<GlobalStyle />
<PersistGate loading={null} persistor={persistor}>
<RouterProvider router={router} />
</PersistGate>
</Provider>
</ThemeProvider>
</React.StrictMode>,
)
그리고 App 혹은 RotuerProvider 를 PersistGate 로 감싸자.
이는 지속되고 있는 상태가 검색되고 redux 에 저장될 때까지 app 의 UI 가 렌더링되는 것을 지연시켜준다.
이 에러는 action 에 직렬화가 불가능한 값을 전달했기 때문에 발생하는 에러다.
정확하게는 Redux Toolkit 에서 자동으로 생성해주는 action 객체가 action 생성자 함수 형태라서 이런 오류가 발생할 수 있다. type 의 인자로 string 이 전달되어야 하는데 함수가 전달되어서 오류가 발생한 것이다.
이때 직렬화란 redux 에서 값을 주고받을 때 object 형태의 값을 string 형태로 변환하는 것 (JSON.stringify) 을 말한다.
역직렬화는 직렬화와 반대로 문자열 혀앹의 객체를 다시 object 형태로 되돌리는 과정을 말한다. (JSON.parse)
redux 가 state, action 에 직렬화가 불가능한 값을 전달할 수 없기에 이 에러가 발생한 것이다.
이때 미들웨어를 추가해서 직렬화 체크를 해제하는 식으로 해결할 수도 있다.
에러가 사라졌다.
질문 카드별로 드래그 & 드랍 기능을 구현해야 했고 적절한 라이브러리인 react-beautifyl-dnd 를 찾게 되어 도입하게 되었다.
npm i react-beautiful-dnd
npm install --save @types/react-beautiful-dnd
typescript 도 같이 사용중이니 타입도 같이 설치했다.
한번 써보면 그 다음부터는 안쓸 이유가 없는 라이브러리다. 그저 input 과 submit, onChange 를 사용하면 입력한 값이 바뀔 때마다 불필요한 재렌더링이 발생할 수 있다.
React Hook Form 은 비제어 컴포넌트를 사용하는 라이브러리이며 이와 동시에 제어 컴포넌트인 MUI 를 사용하기 위해 React Hook Form 의 Controller 를 사용하고자 한다. Controller 컴포넌트를 사용하여 MUI 컴포넌트를 매핑시, react-hook-form 이 해당 필드의 상태를 추적하고 필요한 경우에만 업데이트하는 장점을 이용할 수 있다.
React Hook Form -> 이전에 정리했던 내용을 참고해보자.
Controller 와 useController 사이에 무엇을 사용할지 고민해밨는데 useController 는 Controller 와 같은 props, methods 를 공유하고 재사용 가능한 Controlled input 을 생성할 수 있어서 useController 를 사용하였다.
npm i react-hook-form
import Select from "react-select"
import { useForm, Controller } from "react-hook-form"
import Input from "@material-ui/core/Input"
const App = () => {
const { control, handleSubmit } = useForm({
defaultValues: {
firstName: "",
select: {},
},
})
const onSubmit = (data) => console.log(data)
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="firstName"
control={control}
render={({ field }) => <Input {...field} />}
/>
<Controller
name="select"
control={control}
render={({ field }) => (
<Select
{...field}
options={[
{ value: "chocolate", label: "Chocolate" },
{ value: "strawberry", label: "Strawberry" },
{ value: "vanilla", label: "Vanilla" },
]}
/>
)}
/>
<input type="submit" />
</form>
)
}
import { TextField } from "@material-ui/core";
import { useController, useForm } from "react-hook-form";
function Input({ control, name }) {
const {
field,
fieldState: { invalid, isTouched, isDirty },
formState: { touchedFields, dirtyFields }
} = useController({
name,
control,
rules: { required: true },
});
return (
<TextField
onChange={field.onChange} // send value to hook form
onBlur={field.onBlur} // notify when input is touched/blur
value={field.value} // input value
name={field.name} // send down the input name
inputRef={field.ref} // send input ref, so we can focus on input when error appear
/>
);
}
한글 조합이 안된다.
근데? register 를 하나만 사용할때는 문제가 없는데 2개를 사용시 둘다 한글 조합이 깨져서 영어처럼 분리된 채로 입력이 되었다.
원인을 찾다보니 react hook form 의 register 은 input 혹은 select 요소노드를 등록할 수 있게 해주고 React Hook Form 의 규칙에 따라 유효성 검사를 실시한다.
이것을 위해 string 인 name 을 인자로 전달해야 하는데 위에선 같은 파일 내에서 둘다 id 즉, 동일한 name 을 전달하고 있어서 register 가 제대로 name 을 못찾은 것 같다.
각각 register 에 다른 name 을 전달하니 문제가 해결되었다.
eslint 를 이용해서 import 순서를 자동으로 바꿔보자! - eamon3481
Eslint & Prettier 설정 방법 (feat. VS Code)
JS프로젝트를 TS로 리팩토링하기
[React] 리액트 라우터 - RouterProvider와 CreateBrowserRouter
React Router v6.4 이상에서 Router 다루기(RouterProvider, createBrowserRouter, Route)
React Router 적용
Redux-persist 란?
redux persist npm
RTK non-serializable value 오류 해결 방법
Redux Toolkit - A non-serializable value was detected in an action ...
react-beautiful-dnd 사용 방법
react-beautiful-dnd npm
[React Hook Form] mui와 같이 사용하기 | Controller, useController
React Hook Form docs
react-beautiful-dnd docs 사용법 영상