๐ก WIL 20230807-20230811 : TypeScript React - ์ฑ๋ด, private route, ๋ง์ด ํ์ด์ง ๊ตฌํํ ๊ธฐ๋ฅ ๋์์๋ฆฌ ๋ฐ ํธ๋ฌ๋ธ์ํ ์ ๋ฆฌ
1. ๋งก์ ๊ธฐ๋ฅ
- openai API ์ฌ์ฉํ์ฌ ์ฑ๋ด UI ๊ธฐ๋ฅ ๊ตฌํ
- auth ์ฌ๋ถ์ ๋ฐ๋ผ ์ ๊ทผ ๋ถ๊ฐ๋ฅํ๋๋ก private route ์ค์
- ๋ง์ดํ์ด์ง ํ๋กํ ์์ ๋ฐ ๋ด๊ฐ ์ด ๊ธ ๋ชจ์๋ณด๊ธฐ ํ์ด์ง๋ค์ด์
2. ์ฌ์ฉ ๋ผ์ด๋ธ๋ฌ๋ฆฌ
react-route-dom, redux-toolkit, react-query, supabase, openai, react-hook-form, styled-components
3. ๊ตฌํํ ๊ธฐ๋ฅ ๋์ ์๋ฆฌ
1) ๐ค๐ฌ ์ฑ๋ด ๊ธฐ๋ฅ - openai API, UI ์ํ๊ด๋ฆฌ
(1) openai API
- openai API๋ฅผ ์ฌ์ฉํ์ฌ ์ฌ์ฉ์ ์ ๋ ฅ prompt๋ฅผ ์ ๋ฌํ์ฌ ๋ต๋ณ์ ์ป์ ์ ์์
// lib - openai.ts
import { Configuration, OpenAIApi } from 'openai';
const configuration = new Configuration({
apiKey: process.env.REACT_APP_OPENAI_API_KEY
});
export const openai = new OpenAIApi(configuration);
// components - ChatBot.tsx
const handlePromptSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
dispatch(
addChatLog({
id: shortid.generate(),
role: 'user',
chat: prompt
})
);
setPrompt('');
try {
const { data } = await openai.createCompletion({
model: 'text-davinci-003',
prompt,
temperature: 0.8,
max_tokens: 256
});
if (data?.choices[0]?.text) {
dispatch(
addChatLog({
id: data.id,
role: 'bot',
chat: data.choices[0]?.text
})
);
} else {
alert('์ ์์ ์ผ๋ก ์ ๋ฌ๋์ง ์์์ต๋๋ค. ๋ค์ ์๋ํด์ฃผ์ธ์.');
}
} catch (err) {
alert('์ ์์ ์ผ๋ก ์ ๋ฌ๋์ง ์์์ต๋๋ค. ๋ค์ ์๋ํด์ฃผ์ธ์.');
}
setLoading(false);
};
(2) ์ฑํ UI
- ์ฌ์ฉ์์ ํ๋กฌํํธ์ openai ๋ต๋ณ์ redux toolkit store์ ๋ฐฐ์ด๋ก ์ ์ฅ
- ๋ฐฐ์ด์ ์์(์ฑํ ๋ก๊ทธ)๋ค์ useSelector๋ก ๊ฐ์ ธ์จ ๋ค, map์ผ๋ก role === 'bot'๊ณผ role === 'user'๊ฐ ์ฐจ๋ก๋ก ๋ ๋๋ง๋๊ฒ ํ์ฌ ์ฑํ ์ฒ๋ผ ๋ณด์ด๋๋ก UI ๊ธฐ๋ฅ ๊ตฌํ
// components - ChatBot.tsx
import React, { useRef, useState, useEffect } from 'react';
import { openai } from '../../lib/openai';
import { useAppDispatch, useAppSelector } from '../../hooks';
import { RootState } from '../../redux/config/configStore';
import { addChatLog } from '../../redux/module/chatBotLogSlice';
import shortid from 'shortid';
import LoaderIcon from 'remixicon-react/Loader2LineIcon';
import SendPlaneIcon from 'remixicon-react/SendPlaneFillIcon';
import * as S from '../../styles/StChatBot';
const ChatBot = () => {
const { user } = useAppSelector((state: RootState) => state.user);
const [prompt, setPrompt] = useState('');
const [loading, setLoading] = useState(false);
const chatBotLogs = useAppSelector((state) => state.chatBotLog.logs);
const dispatch = useAppDispatch();
const submitButtonRef = useRef(null);
const chatAreaRef = useRef<HTMLDivElement>(null);
// ์ฑํ
๋ก๊ทธ๊ฐ ์์ด๋ฉด ์คํฌ๋กค ์๋์ผ๋ก ์๋๋ก ๊ฐ๊ฒ๋
useEffect(() => {
if (chatAreaRef.current) {
chatAreaRef.current.scrollTop = chatAreaRef.current.scrollHeight;
}
}, [chatBotLogs]);
const onChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setPrompt(e.currentTarget.value);
};
// ์ง๋ฌธ์ ์ ์กํ๋ textarea์์ ์ํฐ๋ฅผ ์
๋ ฅํ๋ฉด ์ค๋ฐ๊ฟ์ด ์๋ submit์ด ๋๋๋ก
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
if (submitButtonRef.current) {
(submitButtonRef.current as HTMLButtonElement).click();
setPrompt('');
}
}
};
const handlePromptSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
dispatch(
addChatLog({
id: shortid.generate(),
role: 'user',
chat: prompt
})
);
setPrompt('');
try {
const { data } = await openai.createCompletion({
model: 'text-davinci-003',
prompt,
temperature: 0.8,
max_tokens: 256
});
if (data?.choices[0]?.text) {
dispatch(
addChatLog({
id: data.id,
role: 'bot',
chat: data.choices[0]?.text
})
);
} else {
alert('์ ์์ ์ผ๋ก ์ ๋ฌ๋์ง ์์์ต๋๋ค. ๋ค์ ์๋ํด์ฃผ์ธ์.');
}
} catch (err) {
alert('์ ์์ ์ผ๋ก ์ ๋ฌ๋์ง ์์์ต๋๋ค. ๋ค์ ์๋ํด์ฃผ์ธ์.');
}
setLoading(false);
};
return (
<>
<S.ChatContainer>
<S.ChatTitle>๐ค ๋ณ์๋ช
, ํจ์๋ช
์ ๋ฌผ์ด๋ณด์ธ์!</S.ChatTitle>
<S.ChatArea ref={chatAreaRef}>
<S.ChatLogBox>
<S.RoleName>์ฝ๋ฆฐ๋ด ๐</S.RoleName>
<S.BotChatLog>
์๋
ํ์ธ์! <br /> ๋ณ์๋ช
, ํจ์๋ช
์ ๊ณ ๋ฏผ ์ค์ด์ ๊ฐ์? ์ ์๊ฒ ๋ฌผ์ด๋ณด์ธ์!
</S.BotChatLog>
</S.ChatLogBox>
{chatBotLogs.map((chat) => {
if (chat.role === 'bot') {
return (
<S.ChatLogBox key={chat.id}>
<S.RoleName>์ฝ๋ฆฐ๋ด ๐</S.RoleName>
<S.BotChatLog>{chat.chat}</S.BotChatLog>
</S.ChatLogBox>
);
} else if (chat.role === 'user') {
return (
<S.UserPromptBox key={chat.id}>
<S.RoleName>{user ? user.name : ''} ๋</S.RoleName>
<S.UserChatLog>{chat.chat}</S.UserChatLog>
</S.UserPromptBox>
);
}
return null;
})}
</S.ChatArea>
<S.PromptArea>
<S.PromptForm onSubmit={handlePromptSubmit}>
<S.PromptInput
typeof="text"
value={prompt}
onChange={onChange}
onKeyDown={onKeyDown}
maxLength={64}
placeholder="์ง๋ฌธ์ ์
๋ ฅํ์ธ์!"
required
/>
<S.PromptSubmitButton type="submit" ref={submitButtonRef} disabled={!prompt || loading}>
{loading ? <LoaderIcon /> : <SendPlaneIcon />}
</S.PromptSubmitButton>
</S.PromptForm>
</S.PromptArea>
</S.ChatContainer>
</>
);
};
export default ChatBot;
2) ๐โช๏ธ private route ๊ธฐ๋ฅ - react-router-dom
- store์์ user ์ํ๊ฐ user object์ธ์ง null์ธ์ง ๊ฐ์ ธ์์ user ๊ฐ์ฒด๊ฐ ์กด์ฌํ ๋(=๋ก๊ทธ์ธ ์ ์ ๊ฐ ์์ ๋)๋ง ๊ธ ์์ฑ ํ์ด์ง์ ๋ง์ด ํ์ด์ง์ธ '/write', '/mypage' url์ ์ ๊ทผํ ์ ์๋๋ก ๊ธฐ๋ฅ ๊ตฌํ
- user ๊ฐ์ฒด๊ฐ ์๋ ์ํ(user : null)์์ 'korini-project.vercel.app/write' ๋๋ 'korini-project.vercel.app/mypage'๋ก ์ ๊ทผํ ์ main ํ์ด์ง(korini-project.vercel.app/)๋ก ๋ฆฌ๋ค์ด๋ ํธ ๋๋๋ก ํ์์
// Router.tsx
const Router = () => {
const { user } = useAppSelector((state: RootState) => state.user);
// ์ค๊ฐ ์๋ต
{/* PrivateRoute : ๋ก๊ทธ์ธ ์ ์ ์์ผ๋ฉด main ํ์ด์ง๋ก ์ด๋ */}
<Route element={<PrivateRoute />}>
<Route path="/mypage" element={<Mypage />} />
<Route path="/write" element={<Write />} />
</Route>
</Route>
<Route path="*" element={<NotFound />} />
// PrivateRoute.tsx
import { Outlet, Navigate } from 'react-router-dom';
import { useAppSelector } from '../hooks';
import { RootState } from '../redux/config/configStore';
const PrivateRoute = () => {
const { user } = useAppSelector((state: RootState) => state.user);
return user ? <Outlet /> : <Navigate to="/" />
};
export default PrivateRoute;
3) ๐๏ธ๐ ๋ง์ด ํ์ด์ง - supabase API, react-hook-form, pagination
(1) ํ์ฌ ์ ์ ์ ์ํ ๊ด๋ฆฌ
- ์ ์ auth ์ํ๋ฅผ ์ต์๋จ ์ปดํฌ๋ํธ์ธ App.tsx์์ supabase api๋ก ๋ถ๋ฌ์จ ๋ค, ๋ก๊ทธ์ธ ํ ์ ์ ๊ฐ ์์ผ๋ฉด ์ ์ ์ uid, email, ๋๋ค์์ store์ user state๋ก ๊ด๋ฆฌํ์ฌ ๊ฐ ์ปดํฌ๋ํธ์์ ์ ์ญ์ ์ผ๋ก ์ฌ์ฉํ ์ ์๋๋ก ๊ด๋ฆฌ
(2) ๋๋ค์ ์์
- ์ด ๋, supabase Auth์๋ firebase Auth์ displayName๊ณผ ๋ฌ๋ฆฌ ๋๋ค์ ์์๊ฐ ์์ด์, supabase DB์ ๋ฐ๋ก user ์ ๋ณด๋ฅผ ๋ฑ๋กํ์ฌ Auth๊ฐ ์๋ user DB์์ ๋๋ค์ ๊ด๋ฆฌ
- ๋๋ค์ ์์ ์์ฒญ ์ ํ์ฌ ์ฌ์ฉ์์ uid์ supabase user db์ ์ ์ฅ๋์ด์๋ uid๋ฅผ ๋น๊ตํ์ฌ ๋๋ค์ ๋ฐ์ดํฐ๋ฅผ ์ ์ก/์์
- ์์ ํ์๋ ๋ณ๊ฒฝ์ฌํญ์ด ๋ฐ๋ก ๋ ๋๋ง๋ ์ ์๋๋ก store์ user.name์๋ dispatch๋ก ์ ๋ฌํ์ฌ ํ๋ฉด์ ๋ณ๊ฒฝ ํ ๋๋ค์์ด ๋ฐ๋ก ๋ฐ์๋ ์ ์๋๋ก ํจ
(3) ๋น๋ฐ๋ฒํธ ๋ณ๊ฒฝ
- react-hook-form ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ฌ์ฉํ์ฌ ๋ฉ์์ง, ์ ํจ์ฑ ๊ฒ์ฌ(๋น๋ฐ๋ฒํธ ์ต์ ๊ธธ์ด ๋ฐ ๋น๋ฐ๋ฒํธ ํ์ธ ์ ๋ ฅ), ์๋ฌ ๋ฉ์์ง ํธ๋ค๋ง ๊ธฐ๋ฅ์ ๊ตฌํํ์๊ณ , ์ ํจ์ฑ ๊ฒ์ฌ๋ฅผ ํต๊ณผํ๋ฉด supabase Auth์ ์ฌ์ฉ์๊ฐ ์ ๋ ฅํ ๋ณ๊ฒฝ ๋น๋ฐ๋ฒํธ๋ฅผ ์ ์กํ์ฌ ๋น๋ฐ๋ฒํธ๋ฅผ ๋ณ๊ฒฝํ ์ ์๋๋ก ํ์์
// ChangePassword.tsx
import supabase from '../../lib/client';
import { useForm } from 'react-hook-form';
import { ErrorMessage } from '@hookform/error-message';
import * as S from '../../styles/StMyPage';
import * as G from '../../styles/StButton';
interface IFormInput {
password: string;
passwordCheck: string;
}
const ChangePassword = () => {
const { register, handleSubmit, formState } = useForm<IFormInput>();
const onSubmit = async (formValues: IFormInput) => {
try {
const { data, error } = await supabase.auth.updateUser({
password: formValues.password
});
if (error) {
if (error.message === 'New password should be different from the old password.') {
// ์ ๋น๋ฐ๋ฒํธ๊ฐ ์ด์ ๋น๋ฐ๋ฒํธ์ ๊ฐ์ ๊ฒฝ์ฐ
window.alert('๊ธฐ์กด ๋น๋ฐ๋ฒํธ์ ๋ณ๊ฒฝ ๋น๋ฐ๋ฒํธ๊ฐ ๊ฐ์ต๋๋ค. ๋ค๋ฅธ ๋น๋ฐ๋ฒํธ๋ก ์๋ํด์ฃผ์ธ์.');
return false;
} else {
window.alert('๋น๋ฐ๋ฒํธ๊ฐ ์ ์์ ์ผ๋ก ๋ณ๊ฒฝ๋์ง ์์์ต๋๋ค. ๋ค์ ์๋ํด์ฃผ์ธ์.');
return false;
}
}
if (data) {
window.alert('๋น๋ฐ๋ฒํธ๊ฐ ์ ์์ ์ผ๋ก ๋ณ๊ฒฝ๋์์ต๋๋ค.');
return false;
}
} catch (error) {
window.alert('๋น๋ฐ๋ฒํธ ๋ณ๊ฒฝ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค. ๋ค์ ์๋ํด์ฃผ์ธ์.');
return false;
}
};
return (
<>
<S.MyPageForm onSubmit={handleSubmit(onSubmit)}>
<S.LabelInputBox>
<label htmlFor="password">๋น๋ฐ๋ฒํธ ๋ณ๊ฒฝ</label>
<S.MyPageInput
id="password"
type="password"
{...register('password', {
required: '์ ๋น๋ฐ๋ฒํธ๋ฅผ ์
๋ ฅํ์ธ์.',
minLength: {
value: 6,
message: '๋น๋ฐ๋ฒํธ๋ 6์ ์ด์ ์
๋ ฅํด์ฃผ์ธ์.'
}
})}
/>
</S.LabelInputBox>
<ErrorMessage
errors={formState.errors}
name="password"
render={({ message }) => <S.MyPageErrorMsg>{message}</S.MyPageErrorMsg>}
/>
<S.LabelInputBox>
<label htmlFor="passwordCheck">๋น๋ฐ๋ฒํธ ๋ณ๊ฒฝ ํ์ธ</label>
<S.MyPageInput
id="passwordCheck"
type="password"
{...register('passwordCheck', {
validate: (value, formValues) =>
value === formValues.password || '๋น๋ฐ๋ฒํธ์ ๋น๋ฐ๋ฒํธ ํ์ธ์ด ์ผ์นํ์ง ์์ต๋๋ค.'
})}
/>
</S.LabelInputBox>
<ErrorMessage
errors={formState.errors}
name="passwordCheck"
render={({ message }) => <S.MyPageErrorMsg>{message}</S.MyPageErrorMsg>}
/>
<G.Button type="submit">๋น๋ฐ๋ฒํธ ๋ณ๊ฒฝ</G.Button>
</S.MyPageForm>
</>
);
};
export default ChangePassword;
(4) ๋ด๊ฐ ์ด ๊ธ ๊ธฐ๋ฅ
- uid๋ฅผ ํ์ฉํ์ฌ supabase ๊ฒ์๊ธ db์ ๋ด๊ฒจ์๋ ๊ฒ์๊ธ ์์ฑ์ uid๋ฅผ ๋น๊ตํด์, ํ์ฌ ์ ์ ์ ๊ฒ์๊ธ๋ง ํํฐ๋งํ์ฌ ๊ฐ์ ธ์์ ๋ง์ดํ์ด์ง์์ ๋ชจ์์ ํ์ธํ ์ ์๋๋ก ๊ตฌํ
(5) ํ์ด์ง๋ค์ด์
- ์ฌ์ฉ์๊ฐ ์์ฑํ ๊ธ ๋ฐ์ดํฐ๋ฟ๋ง ์๋๋ผ ๊ฐ์๋ฅผ ๋ฐ๋ก api๋ก ๊ฐ์ ธ์ด
- useState๋ก currentPage๋ฅผ ๊ด๋ฆฌ
- ์ ์ ๊ฐ ์์ฑํ ์ ์ฒด ๊ธ ๊ฐ์, ํ ํ์ด์ง๋น ๊ฒ์๊ธ(5๊ฐ ์์๋ก ์ค์ ), currentPage์ ํจ๊ป react-js-pagination ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ฌ์ฉํ์ฌ ํ์ด์ง๋ค์ด์ UI ๊ตฌํ
// api - post.ts
// ๋ด๊ฐ ์ด ๊ธ ์กฐํ
export const ITEMS_PER_PAGE = 5;
const getMyPosts = async (userid: string, pageNum: number): Promise<PostType[]> => {
const startIndex = (pageNum - 1) * ITEMS_PER_PAGE;
const { data } = await supabase
.from('post')
.select('*')
.eq('userid', userid)
.range(startIndex, startIndex + ITEMS_PER_PAGE - 1)
.order('date', { ascending: false });
return data as PostType[];
};
// ๋ด๊ฐ ์ด ๊ธ ์ ์ฒด ๊ฐ์
const getMyPostsNum = async (userid: string) => {
if (!userid) return null;
const { count } = await supabase.from('post').select('*', { count: 'exact' }).eq('userid', userid);
return count ?? 0;
};
// components - mypage - MyPosts.tsx
import { useNavigate } from 'react-router-dom';
import { useAppSelector } from '../../hooks';
import { RootState } from '../../redux/config/configStore';
import { useQueryClient, useQuery } from '@tanstack/react-query';
import { ITEMS_PER_PAGE, getMyPosts, getMyPostsNum } from '../../api/post';
import { useState, useEffect } from 'react';
import Pagination from 'react-js-pagination';
import Loading from '../layout/Loading';
import { PostType } from '../../types/types';
import * as S from '../../styles/StMyPage';
import * as P from '../../styles/StPageButton';
const MyPosts = () => {
const navigate = useNavigate();
const { user } = useAppSelector((state: RootState) => state.user);
const [currentPage, setCurrentPage] = useState(1);
const { data: myPostsNum } = useQuery(['myPostsNum'], () => getMyPostsNum(user?.userid ?? ''));
const maxPostPage = Math.ceil(myPostsNum ? myPostsNum / ITEMS_PER_PAGE : 1);
// ํ๋ฆฌํ์นญ
const queryClient = useQueryClient();
useEffect(() => {
if (currentPage < maxPostPage) {
const nextPage = currentPage + 1;
queryClient.prefetchQuery(['myPosts', nextPage], () => getMyPosts(user?.userid ?? '', nextPage));
}
}, [currentPage, queryClient]);
// ํ์ฌ ํ์ด์ง ๋ฐ์ดํฐ ๋ถ๋ฌ์ค๊ธฐ
const { isLoading, data: myPosts } = useQuery<PostType[]>(['myPosts', currentPage], () =>
getMyPosts(user?.userid ?? '', currentPage)
);
if (isLoading) {
return <Loading />;
}
return (
<>
<S.MainPostsContainer>
{myPosts?.map((myPost) => {
return (
<S.box key={myPost.postid}>
<S.PostBox
onClick={() => {
// ์ ๋๊ฒฝ๋ก
navigate(`/detail/${myPost.postid}`);
}}
>
<S.PostBoxNav>
<div>{myPost.title}</div>
<S.Nickname>{myPost.name}</S.Nickname>
</S.PostBoxNav>
<S.PostContentBox>{myPost.body}</S.PostContentBox>
</S.PostBox>
</S.box>
);
})}
</S.MainPostsContainer>
<P.PageLists>
<Pagination
activePage={currentPage}
itemsCountPerPage={ITEMS_PER_PAGE}
totalItemsCount={myPostsNum ?? 0}
pageRangeDisplayed={5}
prevPageText={`โ`}
nextPageText={`โถ`}
onChange={setCurrentPage}
/>
</P.PageLists>
</>
);
};
export default MyPosts;
4. ํธ๋ฌ๋ธ ์ํ
private route ๊ด๋ จ
(1) ๋ก๊ทธ์ธ ๋ ์ ์ ๊ฐ ์์ ๋์๋ ์ฃผ์์ฐฝ์ ์ง์ '/write'๋ '/mypage'๋ฅผ ์ ๋ ฅํ์ฌ ์ ๊ทผํ๋ ค๊ณ ํ๋ฉด, main('/')์ผ๋ก ํ๊ฒจ์ ธ ๋์ค๋ ํ์
- ์์ธ
- private route ์ฌ๋ถ๋ฅผ ํ๋จํ๋ user ์ํ๋ฅผ rtk store์์ ๋ฐ์์ค๊ธฐ ๋๋ฌธ์, url์ ์ง์ ์ ๋ ฅํ๋ฉด ํ์ด์ง๊ฐ ์๋ก๊ณ ์นจ๋์ด store์์ user๋ฅผ ๋ฐ๋ก ๋ฐ์์ค์ง ๋ชปํ๊ณ ๋ก๊ทธ์ธ ์ ์ ๊ฐ ์๋ ๊ฒ์ผ๋ก ์ธ์ํ์ฌ ๋ฉ์ธ ํ์ด์ง๋ก ํ๊ฒจ์ ธ๋์ด
- ํด๊ฒฐ๋ฐฉ์ ๋ชจ์
- localstorage์์ auth-token๊ฐ์ getItemํด์ auth-token๊ฐ์ ์ ๋ฌด๋ก auth ์ ์ ์ ์กด์ฌ๋ฅผ ๋ฐ์์ค๊ณ ์ ํจ
- ์คํจ
- supabase์ ๊ฒฝ์ฐ auth-token์ key๊ฐ์ด ์ฃผ๊ธฐ์ ์ผ๋ก ๋ฐ๋๊ธฐ ๋๋ฌธ์, ์ด ๋ฐ๋๋ ๊ฐ์ ์ด๋ป๊ฒ ๊ฐ์ ธ์์ผํ ์ง ํด๊ฒฐ ๋ฐฉ์์ ์ฐพ์ง ๋ชปํด์ ์ต์ข ์ ์ผ๋ก๋ ์ด ๋ฌธ์ ๋ฅผ ํด๊ฒฐํ์ง ๋ชปํ์์
'โ๏ธ What I Learned > TIL' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
[TIL] npm vs npx vs yarn (0) | 2023.08.15 |
---|---|
[TIL] ๊ฐ๋จํ Recoil ์ฒซ ์ฌ์ฉ๊ธฐ (0) | 2023.08.14 |
[TIL] React ์ฑ๋ด ๋ง๋ค๊ธฐ2 - redux-toolkit์ผ๋ก ์ฑ๋ด on/off์ฌ๋ถ ์ ์ญ์ ์ธ ์ํ๊ด๋ฆฌ (0) | 2023.08.08 |
[TIL] React ์ฑ๋ด ๋ง๋ค๊ธฐ1 - OpenAI api ์ธํ ๋ฐ ์ฌ์ฉ ๋ฐฉ๋ฒ (ChatGPT) (0) | 2023.08.07 |
[TIL] useMemo, useCallback (0) | 2023.08.04 |