5. 상태 관리와 리덕스 패키지
5-1. 리덕스 기본 개념 이해하기
리덕스와 리덕스 관련 필수 패키지
플럭스(flux) : 앱 수준 상태, 즉 여러 컴포넌트가 공유하는 상태를 리액트 방식으로 구현하는 방법
리덕스: 플럭스 설계 규격을 준수하는 오픈소스 라이브러리
앱 수준 상태 알아보기
앱 수준 상태(app-level states) : 앱을 구성하는 모든 컴포넌트가 함께 공유할 수 있는 상태
- Provider 컴포넌트와 store 속성
import { Provider as ReduxProvider } from 'react-redux';
- 리덕스 저장소와 리듀서, 액션 알아보기
타입스크립트 언어로 리덕스 기능을 사용할 때는 먼저 다음처럼 앱 수준 상태를 표현하는 AppState와 같은 타입을 선언해야 함
export type AppState = {}
리덕스 저장소(redux store) : AppState 타입 데이터를 저장하는 공간
리듀서(reducer) : 현재 상태 / 액션이라는 2가지 매개변수로 새로운 상태를 만들어서 반환
ㄴ액션 : type이란 이름의 속성이 있는 평범한 자바스크립트 객체
- 스토어 객체 관리 함수
import { configureStore } from '@reduxjs/toolkit';
configureStore : 리듀서에서 반환한 새로운 상태를 스토어라는 객체로 정리해 관리하는 함수
리덕스로 시계 구현하기
//src/App.tsx
import './App.css';
import { Action, configureStore } from '@reduxjs/toolkit';
import { Provider as ReduxProvider } from 'react-redux';
import { useStore } from './store/useStore';
import UseReducerClock from './pages/UseReducerClock';
import ReduxClock from './pages/ReduxClock';
function App() {
const store = useStore()
return(
<ReduxProvider store={store}>
<main className='p-8'>
<UseReducerClock />
<ReduxClock />
</main>
</ReduxProvider>
)
}
export default App;
//src/store/AppState.ts
export type AppState = {
today: Date
}
//src/store/rootReducer.ts
import { Action } from "redux"
import { AppState } from "./AppState"
const initialAppState = {
today: new Date()
}
export const rootReducer = (state: AppState = initialAppState, action: Action) => state
//src/store/useStore.ts
import { configureStore } from "@reduxjs/toolkit"
import { rootReducer } from "./rootReducer"
import { useMemo } from "react"
const initializeStore = () => {
const store = configureStore({
reducer: rootReducer,
middleware: getDefaultMiddleware => getDefaultMiddleware()
})
return store
}
export function useStore() {
const store = useMemo(() => initializeStore(), [])
return store
}
useSelector 훅 사용하기
리덕스 저장소에 어떤 내용이 저장되었는지 알고자 스토어의 상탯값을 반환해주는 훅
//src/pages/ReduxClock.tsx
import { useSelector } from "react-redux"
import { AppState } from "../store/AppState"
export default function ReduxClock() {
const today = useSelector<AppState, Date>(state => state.today)
return (
<div className="flex flex-col items-center justify-center mt-16">
<h2 className="text-5xl font-bold text-center">ReduxClock</h2>
<h2 className="mt-4">{today.toLocaleDateString()}</h2>
</div>
)
}
리덕스 액션
액션: 저장소의 특정 속성값만 변경하고 싶을 때 사용하는 방식
반드시 type이란 이름의 속성이 있어야 함 (리덕스 패키지에서 Action 타입 제공)
리덕스 리듀서
리듀서 함수의 목적: 첫 번째 매개변수에 담긴 과거 상태값(preState)을 바탕으로 새로운 상태값(newState) 반환
//rootReducer
import { Action } from "redux"
import { AppState } from "./AppState"
const initialAppState = {
today: new Date()
}
export const rootReducer = (prevState: AppState = initialAppState, action: Action) => {
const newState = {...prevState}
return newState
}
action.type을 switch문을 이용해서 원하는 타입의 액션이 들어올 때만 state를 변경하고 그렇지 않으면 state를 변경하지 않고 그대로 반환
useDispatch
const dispatch = useDispatch()
훅 호출시 dispatch() 함수를 얻음
dispatch() 함수를 사용해 리덕스 저장소에 저장된 AppState 객체 멤버 전부나 일부를 변경할 수 있음
dispatch({type: 'setToday', today: new Date()})
^ type이 setToday인 액션을 dispatch()를 통해 리덕스 저장소로 보내는 코드
[ dispatch(액션) ] ----> [ 리듀서 ] ----> [ 리덕스 저장소 ]
- 리덕스 저장소에 저장된 앱 상태의 일부 속성값 변경을 위해서는 일단 액션을 만들어야 함
- 액션은 반드시 dispatch() 함수로 리덕스 저장소에 전달해야 함
- 액션이 리덕스 저장소에 전달될 때 리듀서가 관여
function reducer(state, action)
- state: 리덕스 저장소가 생성 주체
- action: dispatch(액션) 코드가 실행되면 action이 리듀서로 전달됨
useReducer
리덕스의 리듀서와 같은 기능.
단 리덕스의 상태는 앱의 모든 컴포넌트에서 접근할 수 있지만(전역상태) useReducer 훅의 상태는 훅을 호출한 컴포넌트 안에서만 유효(지역상태)함
const [상태, dispatch] = useReducer(리듀서, 상태_초깃값)
5-2. 리듀서 활용하기
combineReducer()
여러 리듀서를 통합하여 새로운 리듀서를 만들어줌. 매개변수로 reducer을 받고, 타입은 ReducersMapObject
* 실습에서 사용할 copy 공용 파일들
//types.ts
import { Action } from "redux"
export type State = any
export type Actions = Action
//reducers.ts
import * as T from './types'
const initialState: T.State = {}
export const reducer = (state: T.State = initialState, action: T.Actions) => {
return state
}
src/store/AppState.ts에 여러 리듀서를 import하고 앱 상태를 독립적 동작 멤버 상태로 구성
import * as Clock from './clock'
import * as Counter from './counter'
export type AppState = {
clock: Clock.State
counter: Counter.State
}
그리고 src/store/rootReducer.ts에 루트 리듀서 구현
import { combineReducers } from "redux";
import * as Clock from './clock'
import * as Counter from './counter'
export const rootReducer = combineReducers({
clock: Clock.reducer,
counter: Counter.reducer,
})
상태이름: 해당리듀서 형태의 조합을 모두 결합해 새로운 루트 리듀서를 만듦
Clock 만들기
먼저 멤버 상태에 대한 타입 선언
//src/store/clock/types.ts
import { Action } from "redux"
export type State = string
export type SetClockAction = Action<'@clock/setClock'> & {
payload: State
}
export type Actions = SetClockAction
setclockaction타입의 액션 객체를 생성하는 액션 생성기 구현
//src/store/clock/actions.ts
import type * as T from './types'
export const setClock = (payload: T.State): T.SetClockAction => ({
type: '@clock/setClock',
payload
})
리듀서 구현
//src/store/clock/reducers.ts
import * as T from './types'
const initialState: T.State = new Date().toISOString()
export const reducer = (state: T.State = initialState, action: T.Actions) => {
switch(action.type){
case '@clock/setClock':
return action.payload
}
return state
}
페이지 작성
import { useDispatch, useSelector } from 'react-redux'
import * as C from '../store/clock'
import { AppState } from '../store/AppState'
import { useInterval } from '../hooks/useInterval'
export default function ClockTest() {
const clock = new Date(useSelector<AppState, C.State>(state=>state.clock))
const dispatch = useDispatch()
useInterval(() => dispatch(C.setClock(new Date().toISOString())))
return (
<section className="mt-4">
<h2 className="text-4xl font-bold text-center">ClockTest</h2>
<div className="flex flex-col items-center mt-4">
<p className='text-2xl text-blue-600 text-bold'>
{clock.toLocaleTimeString()}
</p>
<p className='text-lg text-blue-400 text-bold'>
{clock.toLocaleDateString()}
</p>
</div>
</section>
)
}
Counter 만들기
타입 선언
import { Action } from "redux"
export type State = number
export type SetCounterAction = Action<'@counter/setCounter'> & {
payload: State
}
export type Actions = SetCounterAction
액션
import type * as T from './types'
export const setCounter = (payload: T.State): T.SetCounterAction => ({
type: '@counter/setCounter',
payload
})
export const increaseCounter = () => setCounter(1)
export const decreaseCounter = () => setCounter(-1)
리듀서
import * as T from './types'
const initialState: T.State = 0
export const reducer = (state: T.State = initialState, action: T.Actions) => {
switch(action.type){
case '@counter/setCounter':
return state + action.payload
}
return state
}
페이지
import { useDispatch, useSelector } from 'react-redux'
import * as C from '../store/counter'
import { AppState } from '../store/AppState'
import { useCallback } from 'react'
export default function CounterTest() {
const dispatch = useDispatch()
const counter = useSelector<AppState, C.State>(({counter}) => counter)
const increase = useCallback(() => dispatch(C.increaseCounter()), [dispatch])
const decrease = useCallback(() => dispatch(C.decreaseCounter()), [dispatch])
return(
<section className='mt-4'>
<h2>CounterTest</h2>
<div className='flex justify-center p-4 mt-4'>
<span className='text-3xl material-icons' onClick={increase}>add_circle</span>
<h3>{counter}</h3>
<span className='text-3xl material-icons' onClick={decrease}>remove_circle</span>
</div>
</section>
)
}
'@이름/' 접두사 사용
유입된 액션은 combineReducers()가 결합한 모든 리듀서에 전송되므로 액션 타입을 접두사가 없는 이름으로 지으면 type값이 겹칠 수 있으며 의도하지 않은 리듀서가 잘못된 액션을 처리하다 오류가 발생할 수 있음
액션의 행선지를 명확하게 해주는 용도
payload 변수이름 사용
AppState를 구성하는 멤버 상태의 타입들이 수시로 변하기 때문에 더 복잡한 상태로 변경될 수 있음
상태 이름을 자연스럽게 짓기 위함
리듀서는 순수 함수여야 한다
순수 함수(pure function)가 만족해야 하는 요건
- 함수 몸통에서 입력 매개변수의 값을 변경하지 않는다(입력 매개변수는 상수나 읽기전용으로만 사용)
- 함수는 함수 몸통에서 만들어진 결과를 즉시 반환한다
- 함수 내부에 전역변수나 정적변수를 사용하지 않는다
- 함수가 예외를 발생시키지 않는다
- 함수가 콜백 함수 형태로 구현되어 있거나, 함수 몸통에 콜백 함수를 사용하는 코드가 없다
- 함수 몸통에 비동기 방식으로 동작하는 코드가 없다
5-3. 리덕스 미들웨어 이해하기
리덕스 미들웨어
리듀서 앞단에서 부작용이 있는 코드들을 실행하여 얻은 결과를 리듀서 쪽으로 넘겨주는 역할
action을 매개변수로 받는 함수를 반환함
몸통에서 next 함수를 호출해 다음 미들웨어나 리듀서에 액션을 전달해야 함
next 함수의 반환값: 각각의 미들웨어를 거쳐 최종 리듀서까지 전달된 후 처리되어 돌아온 액션
(next: Dispatch) => (action: Action) => {
return next(action)
}
로거 미들웨어
미들웨어에 유입되는 액션과 리듀서 호출 전후의 앱상태를 콘솔에 출력
//logger.ts
import { Action, Dispatch } from "redux";
export default function logger<S = any>({getState}: {getState: () => S}) {
return (next: Dispatch) => (action: Action) => {
console.log('state before next', getState())
console.log('action', action)
const returnedAction = next(action)
console.log('state after next', getState())
return returnedAction
}
}
getState() : 현재 리덕스 저장소에 담긴 모든 상탯값 가져오기
next() 호출 전 저장소 상태와 액션을 출력하고 next() 호출 후 변경된 상태를 출력.
미들웨어는 next() 함수로 얻은 반환값을 다시 반환해야 함 (return returnedAction)
ㄴ 반환하면 이전 미들웨어 몸통에 있는 next(action)의 반환값이 되므로 후처리 가능
로거 미들웨어 적용
//useStore.ts
import { configureStore } from "@reduxjs/toolkit"
import logger from "./logger"
import { rootReducer } from "./rootReducer"
import { useMemo } from "react"
const useLogger = process.env.NODE_ENV !== 'production' //개발 서버일 때만 사용
const initializeStore = () => {
const middleware: any[] = []
if(useLogger){
middleware.push(logger)
}
const store = configureStore({
reducer: rootReducer,
middleware: getDefaultMiddleware => getDefaultMiddleware()
})
return store
}
export function useStore() {
const store = useMemo(() => initializeStore(), [])
return store
}
configureStore()의 선택속성인 middleware에 설정한 기본 미들웨어 getDefaultMiddleware()에 logger 미들웨어 추가
작동 확인용 페이지
//src/pages/LoggerTest.ts
import { useEffect } from "react"
import { useDispatch } from "react-redux"
export default function LoggerTest() {
const dispatch = useDispatch()
useEffect(() => {
dispatch({type: 'cado', paylad: 'babo'})
}, [dispatch])
return (
<section className="mt-4">
<h2 className="text-3xl font-bold text-center">LoggerTest</h2>
<div className="mt-4"></div>
</section>
)
}
redux-logger 패키지의 logger 함수로 같은 기능 사용 가능
npm i redux-logger
썽크 미들웨어
action의 타입이 함수면 action을 함수로서 호출해 주는 기능을 추가한 미들웨어
로딩 UI 구현하기
loading 멤버 속성 구현
앞에 했던 것처럼 타입, actions, reducers 선언
import { Action } from "redux"
export type State = boolean
export type SetLoadingAction = Action<'@loading/setLoadingAction'> & {
payload: State
}
export type Actions = SetLoadingAction
import type * as T from './types'
export const setLoading = (payload: T.State): T.SetLoadingAction => ({
type: '@loading/setLoadingAction',
payload
})
import * as T from './types'
const initialState: T.State = false;
export const reducer = (state: T.State = initialState, action: T.Actions) => {
switch(action.type){
case '@loading/setLoadingAction':
return action.payload
default:
return state
}
}
- 버튼을 누르면 로딩 화면을 3초 보여주고 이후 자동으로 사라지는 기능 구현
//doTimedLoading.ts
import { Dispatch } from "redux";
import { setLoading } from "./actions";
export const doTimedLoading =
(duration: number = 3*1000) =>
(dispatch: Dispatch) => {
dispatch(setLoading(true))
const timerId = setTimeout(() => {
clearTimeout(timerId)
dispatch(setLoading(false))
}, duration)
}
먼저 setLoading(true) 액션을 dispatch 함수로 리덕스 저장소에 보냄
그리고 duration만큼 시간이 경과되어 setTimeout 호출 때 설정한 콜백 함수가 동작하면 setLoading(false) 액션을 닷 ㅣ리덕스 저장소에 보내 로딩 상태를 false로 변경
확인용 페이지를 만들자
import { useDispatch, useSelector } from "react-redux";
import * as L from "../store/loading"
import { useCallback } from "react";
import { AppState } from "../store";
export default function LoadingTest() {
const dispatch = useDispatch()
const loading = useSelector<AppState, L.State>(({loading}) => loading)
const doTimedLoading = useCallback(() => {
dispatch<any>(L.doTimedLoading(1000))
}, [dispatch])
return(
<section className="mt-4">
<h1>LoadingTest</h1>
<div className="mt-4">
<div className="flex justify-center mt-4">
<button className="btn btn-primary"
onClick={doTimedLoading}
disabled={loading}>DO TIMED LOADING</button>
</div>
{loading && (
<div className="flex items-center justify-center">
<button className="btn btn-circle loading"></button>
</div>
)}
</div>
</section>
)
}
- 오류 메시지 구현
//타입
import { Action } from "redux"
export type State = string
export type SetErrorMessageAction = Action<'@errorMessage/setErrorMessage'> & {
payload: State
}
export type Actions = SetErrorMessageAction
//actions
import type * as T from './types'
export const setErrorMessage = (payload: T.State): T.SetErrorMessageAction => ({
type: '@errorMessage/setErrorMessage',
payload
})
//reducers
import type * as T from './types'
const initialState: T.State = ''
export const reducer = (state: T.State=initialState, action: T.Actions) => {
switch(action.type){
case '@errorMessage/setErrorMessage':
return action.payload
default:
return state
}
}
//generateErrorMessage
import { Dispatch } from "redux";
import { setErrorMessage } from "./actions";
export const generateErrorMessage =
(errorMessage: string = 'some error occurred') =>
(dispatch: Dispatch) => {
dispatch(setErrorMessage(''))
try {
throw new Error(errorMessage)
} catch(e) {
if (e instanceof Error) {
dispatch(setErrorMessage(e.message))
}
}
}
//ErrorMessageTest.tsx
import { useDispatch, useSelector } from "react-redux";
import { AppState } from "../store/AppState";
import * as E from '../store/errorMessage'
import { useCallback } from "react";
export default function ErrorMessageTest() {
const dispatch = useDispatch()
const errorMessage = useSelector<AppState, E.State>(({errorMessage}) => errorMessage)
const generateErrorMessage = useCallback(() => {
dispatch<any>(E.generateErrorMessage('ERROR!!!!'))
}, [dispatch])
return(
<section>
<h2>ErrorMessageTest</h2>
<div className="mt-4">
<div className="flex justify-center mt-4">
<button className="btn btn-sm btn-primary" onClick={generateErrorMessage}>
GENERATE ERROR MESSAGE
</button>
</div>
<div className="flex items-center justify-center bg-red-200">
<p>error: {errorMessage}</p>
</div>
</div>
</section>
)
}
5-4. 트렐로 따라 만들기
react-dnd 패키지 설치
- 드래그 앤 드롭 기능을 좀 더 쉽게 구현할 수 있게 해주는 패키지
npm i react-dnd react-dnd-html5-backend
npm i -D @types/react-dnd
react-dnd 컴포넌트를 사용하려면 DndProvider 컴포넌트가 최상위 컴포넌트로 동작해야 한다.
import { DndProvider } from 'react-dnd';
import './App.css';
import { HTML5Backend } from 'react-dnd-html5-backend';
function App() {
return(
<DndProvider backend={HTML5Backend}>
</DndProvider>
)
}
export default App;
사용할 것들 store에 디렉터리 추가, AppState 추가
import * as L from './listEntities'
import * as LO from './listidOrders'
import * as LC from './listidCardidOrders'
import * as C from './cardEntities'
export type AppState = {
listEntities: L.State,
listidOrders: LO.State,
listidCardidOrders: LC.State,
cardEntities: C.State
}
루트리듀서 구현
import { combineReducers } from "redux";
import * as L from './listEntities'
import * as LO from './listidOrders'
import * as LC from './listidCardidOrders'
import * as C from './cardEntities'
export const rootReducer = combineReducers({
listEntities: L.reducer,
listidOrders: LO.reducer,
listidCardidOrders: LC.reducer,
cardEntities: C.reducer
})
App.tsx 정리
import { DndProvider } from 'react-dnd';
import './App.css';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { Provider as ReduxProvider } from 'react-redux';
import { useStore } from './store';
import Board from './pages/Board';
function App() {
const store = useStore()
return(
<ReduxProvider store={store}>
<DndProvider backend={HTML5Backend}>
<Board />
</DndProvider>
</ReduxProvider>
)
}
export default App;
기본작업 끝
CreateListForm 컴포넌트 구현하기
새로운 목록을 생성하는 기능을 가진 컴포넌트. Board 디렉터리 안에 만든다
//Board/CreateListForm.tsx
import { ChangeEvent, FC, useCallback, useState } from 'react'
import * as D from '../../data'
import { Icon } from '../../theme/daisyui'
export type CreateListFormProps = {
onCreateList: (uuid: string, title: string) => void
}
const CreateListForm: FC<CreateListFormProps> = ({onCreateList}) => {
const [value, setValue] = useState<string>(D.randomTitleText())
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setValue(() => e.target.value)
}, [])
const addList = useCallback(() => {
onCreateList(D.randomUUID(), value)
setValue(() => D.randomTitleText())
}, [value, onCreateList])
return(
<div className='flex p-2'>
<input placeholder='title' value={value} onChange={onChange}
className='input-xs input-bordered input input-primary' />
<Icon name="add" onClick={addList} disabled={!value.length}
className='ml-2 btn-primary btn-xs'/>
</div>
)
}
export default CreateListForm
//Board/index.tsx
import { useCallback } from "react";
import CreateListForm from "./CreateListForm";
export default function Board() {
const onCreateList = useCallback((uuid: string, title: string) => {
console.log('onCreateList', uuid, title)
}, [])
return (
<section className="mt-4">
<h1>Board</h1>
<div className="mt-4">
<CreateListForm onCreateList={onCreateList} />
</div>
</section>
)
}
랜덤한 타이틀이 input value로 출력되고, 아이콘 버튼을 누르면 콘솔에 uuid와 title을 출력
ts의 Record 타입
색인 연산자를 사용해 객체의 특정 속성값을 설정하거나 얻어올 수 있게 함
Record 타입 사용 시 Record<키타입, 값타입> 형태로 2개의 타입 변수 지정 필요
listidOrders 멤버 상태 구현하기
생성한 목록의 uuid값을 배열에 담아 웹페이지에 어떤 순서로 표시할 것인지 결정
//listidOrders/types.ts
import { Action } from "redux"
import { UUID } from "../commonTypes"
export type State = UUID[]
export type SetListidOrders = Action<'@listidOrders/set'> & {
payload: State
}
export type AddListidToOrders = Action<'@listidOrders/add'> & {
payload: UUID
}
export type RemoveListidFromOrders = Action<'@listidOrders/remove'> & {
payload: UUID
}
export type Actions = SetListidOrders | AddListidToOrders | RemoveListidFromOrders
//listidOrders/actions.ts
import type * as T from './types'
export const setListidOrders = (payload: T.State): T.SetListidOrders => ({
type: '@listidOrders/set',
payload
})
export const addListidOrders = (payload: T.UUID): T.AddListidToOrders => ({
type: '@listidOrders/add',
payload
})
export const removeListidFromOrders = (payload: T.UUID): T.RemoveListidFromOrders => ({
type: '@listidOrders/remove',
payload
})
//listidOrders/reducers.ts
import * as T from './types'
const initialState: T.State = []
export const reducer = (state: T.State = initialState, action: T.Actions) => {
switch(action.type) {
case '@listidOrders/set':
return action.payload
case '@listidOrders/add':
return [...state, action.payload]
case '@listidOrders/remove':
return state.filter(uuid => uuid !== action.payload)
default:
return state
}
}
listEntities 멤버 상태 구현하기
commonTypes.ts에 선언했던 List 타입 객체들을 엔티티 방식으로 저장하는 역할
//listEntities/types.ts
import { Action } from "redux"
import type {List} from '../commonTypes'
export * from '../commonTypes'
export type State = Record<string, List>
export type AddListAction = Action<'@listEntities/add'> & {
payload: List
}
export type RemoveListAction = Action<'@listEntities/remove'> & {
payload: string
}
export type Actions = AddListAction | RemoveListAction
//listEntities/actions.ts
import type * as T from './types'
export const addList = (payload: T.List): T.AddListAction => ({
type: '@listEntities/add',
payload
})
export const removeList = (payload: string): T.RemoveListAction => ({
type: '@listEntities/remove',
payload
})
//listEntities/reducers.ts
import * as T from './types'
const initialState: T.State = {}
export const reducer = (state: T.State = initialState, action: T.Actions) => {
switch(action.type){
case '@listEntities/add':
return {...state, [action.payload.uuid]: action.payload}
case '@listEntities/remove': {
const newState = {...state}
delete newState[action.payload]
return newState
}
default:
return state
}
}
리듀서의 매개변수 state값을 바꾸지 않으므로 위와 같이 newState로 깊은 복사 방식을 사용해 연산자 적용해야 한다.
listOrders, listEntities 적용하여 보드 수정
BoardList 컴포넌트 구현하기
생성한 목록을 화면에 표시하고, 삭제 버튼을 누르면 해당 목록을 제거하는 컴포넌트
//BoardList/index.tsx
import { FC } from "react"
import { List } from "../../store/commonTypes"
import { Icon } from "../../theme/daisyui"
export type BoardListProps = {
list: List
onRemoveList?: () => void
}
const BoardList: FC<BoardListProps> = ({list, onRemoveList, ...props}) => {
return(
<div {...props} className="p-2 m-2 border border-gray-300 rounded-lg">
<div className="flex justify-between mb-2">
<p className="w-32 text-sm font-bold underline line-clamp-1">{list.title}</p>
<div className="flex justify-between ml-2">
<Icon name="remove" className="btn-error btn-xs" onClick={onRemoveList} />
</div>
</div>
</div>
)
}
export default BoardList
Board 페이지에 적용
추가, 삭제 기능 완료
리덕스 기능을 커스텀 훅으로 만들기
//store/useLists.ts
import { useSelector } from "react-redux"
import { useDispatch } from "react-redux"
import { AppState } from "./AppState"
import { List } from "./commonTypes"
import { useCallback } from "react"
import * as LO from './listidOrders'
import * as L from './listEntities'
export const useLists = () => {
const dispatch = useDispatch()
const lists = useSelector<AppState, List[]>(({listidOrders, listEntities}) =>
listidOrders.map(uuid => listEntities[uuid])
)
const onCreateList = useCallback((uuid: string, title: string) => {
const list = {uuid, title}
dispatch(LO.addListidOrders(list.uuid))
dispatch(L.addList(list))
}, [dispatch])
const onRemoveList = useCallback((listid: string) => () => {
dispatch(L.removeList(listid))
dispatch(LO.removeListidFromOrders(listid))
}, [dispatch])
return {lists, onCreateList, onRemoveList}
}
Board에서 기능을 따로 빼서 커스텀 훅 useLists로 만들고 Board/index.tsx는 보다 간결한 코드 사용
//Board/index.tsx
import { useMemo } from "react";
import CreateListForm from "./CreateListForm";
import { Title } from "../../components";
import BoardList from "../BoardList";
import { useLists } from "../../store/useLists";
export default function Board() {
const {lists, onRemoveList, onCreateList} = useLists()
const children = useMemo(
() =>
lists.map(list => (
<BoardList key={list.uuid} list={list} onRemoveList={onRemoveList(list.uuid)} />
)),
[lists, onRemoveList]
)
return (
<section className="mt-4">
<Title>Board</Title>
<div className="flex flex-wrap p-2 mt-4">
{children}
<CreateListForm onCreateList={onCreateList} />
</div>
</section>
)
}
ListCard 컴포넌트 구현하기
BoardList에 카드를 추가하는 기능
//ListCard/index.tsx
import { FC } from "react"
import { ICard } from "../../data"
import { Avatar, Div } from "../../components"
import { Icon } from "../../theme/daisyui"
export type ListCardProps = {
card: ICard
onRemove?: () => void
onClick?: () => void
}
const ListCard: FC<ListCardProps> = ({card, onRemove, onClick}) => {
const {image, writer} = card
const {avatar, name, jobTitle} = writer
return(
<Div className="m-2 border shadow-lg rounded-xl" width="10rem" onClick={onClick}>
<Div src={image} className="relative h-20">
<Icon name="remove" className="absolute right-1 top-1 btn-primary btn-xs"
onClick={onRemove} />
</Div>
<Div className="flex flex-col p-2">
<Div minHeight="4rem" height="4rem" maxHeight="4rem">
<Div className="flex flex-row items-center">
<Avatar src={avatar} size="2rem" />
<Div className="ml-2">
<p className="text-xs font-bold">{name}</p>
<p className="text-xs text-gray-500">{jobTitle}</p>
</Div>
</Div>
</Div>
</Div>
</Div>
)
}
export default ListCard
cardEntities 멤버 상태 구현하기
cardEntities 디렉터리에 타입/액션/리듀서 구현해주면 되는데 listEntities에서 타입만 Card타입을 사용하면 되므로 코드 블럭은 생략...
listCardidOrders 멤버 상태 구현하기
카드는 각각 특정 목록에 소속되어 있고 드래그앤드롭으로 순서를 바꿀 수 있음 -> 특정 목록이 어떤 카드를 어떤 순서로 가지고 있는지 나타내는 정보 필요
import { Action } from "redux"
import * as CT from '../commonTypes'
export * from '../commonTypes'
export type State = Record<CT.UUID, CT.UUID[]>
export type SetListidCardids = Action<'@listidCardids/set'> & {
payload: CT.ListidCardidS
}
export type RemoveListidAction = Action<'@listidCardids/remove'> & {
payload: CT.UUID
}
export type PrependCardidToListidAction = Action<'@listidCardids/prependCardid'> & {
payload: CT.ListidCardid
}
export type RemoveCardidFromListidAction = Action<'@listidCardids/removeCardid'> & {
payload: CT.ListidCardid
}
export type Actions = SetListidCardids | RemoveListidAction | PrependCardidToListidAction | RemoveCardidFromListidAction
이름 너무 껴요...
여튼 액션도 만들고
import type * as T from './types'
import * as CT from '../commonTypes'
//목록 uuid의 속성에 카드 uuid의 배열을 추가
export const setListidCardids = (payload: CT.ListidCardidS): T.SetListidCardids => ({
type: '@listidCardids/set',
payload
})
//listidOrders에서 특정 목록이 삭제되면 listidCardidOrders에서도 삭제해 메모리 낭비 막음
export const removeListid = (payload: string): T.RemoveListidAction => ({
type: '@listidCardids/remove',
payload
})
//특정 목록이 가지고 있는 카드 uuid들 앞에 새로운 카드 uuid 삽입
export const prependCardidToListid = (
payload: CT.ListidCardid
): T.PrependCardidToListidAction => ({
type: '@listidCardids/prependCardid',
payload
})
//특정 목록이 가지고 있는 카드 uuid들 맨 뒤에 새로운 카드 uuid 삽입
export const appendCardidToListid = (
payload: CT.ListidCardid
): T.AppendCardidToListidAction => ({
type: '@listidCardids/appendCardid',
payload
})
//특정 카드가 삭제될 때 목록에 있는 카드 uuid를 지우는 용도
export const removeCardidFromListid = (
payload: CT.ListidCardid
): T.RemoveCardidFromListidAction => ({
type: '@listidCardids/removeCardid',
payload
})
리듀서도..
import * as T from './types'
const initialState: T.State = {}
export const reducer = (state: T.State = initialState, action: T.Actions) => {
switch(action.type){
case '@listidCardids/set':
return {...state, [action.payload.listid]: action.payload.cardids}
case '@listidCardids/remove': {
const newState = {...state}
delete newState[action.payload]
return newState
}
case '@listidCardids/prependCardid': {
const cardids = state[action.payload.listid]
return {...state, [action.payload.listid]: [action.payload.cardid, ...cardids]}
}
case '@listidCardids/appendCardid': {
const cardids = state[action.payload.listid]
return {...state, [action.payload.listid]: [...cardids, action.payload.cardid]}
}
case '@listidCardids/removeCardid': {
const cardids = state[action.payload.listid]
return {
...state,
[action.payload.listid]: cardids.filter(id => id !== action.payload.cardid)
}
}
default:
return state
}
}
이 내용도 useLists 훅에 반영해준다
//useLists.ts
import { useSelector } from "react-redux"
import { useDispatch } from "react-redux"
import { AppState } from "./AppState"
import { List } from "./commonTypes"
import { useCallback } from "react"
import * as LO from './listidOrders'
import * as L from './listEntities'
import * as C from './cardEntities'
import * as LC from './listidCardidOrders'
export const useLists = () => {
const dispatch = useDispatch()
const lists = useSelector<AppState, List[]>(({listidOrders, listEntities}) =>
listidOrders.map(uuid => listEntities[uuid])
)
const listidCardidOrders = useSelector<AppState, LC.State>(
({listidCardidOrders}) => listidCardidOrders
)
const onCreateList = useCallback((uuid: string, title: string) => {
const list = {uuid, title}
dispatch(LO.addListidOrders(list.uuid))
dispatch(L.addList(list))
dispatch(LC.setListidCardids({listid: list.uuid, cardids: []}))
}, [dispatch])
const onRemoveList = useCallback((listid: string) => () => {
listidCardidOrders[listid].forEach(cardid => {
dispatch(C.removeCard(cardid))
})
dispatch(LC.removeListid(listid))
dispatch(L.removeList(listid))
dispatch(LO.removeListidFromOrders(listid))
}, [dispatch, listidCardidOrders])
return {lists, onCreateList, onRemoveList}
}
useCards 커스텀 훅 만들기
카드 관련 코드 간결하게 만들기 위해 커스텀 훅 사용
//store/useCards.ts
import type {Card, UUID} from './commonTypes'
import * as C from './cardEntities'
import * as LC from './listidCardidOrders'
import * as D from '../data'
import { useDispatch } from 'react-redux'
import { useSelector } from 'react-redux'
import { AppState } from './AppState'
import { useCallback } from 'react'
export const useCards = (listid: UUID) => {
const dispatch = useDispatch()
const cards = useSelector<AppState, LC.Card[]>(({cardEntities, listidCardidOrders}) =>
listidCardidOrders[listid].map(uuid => cardEntities[uuid]))
const onPrependCard = useCallback(() => {
const card = D.makeRandomCard()
dispatch(C.addCard(card))
dispatch(LC.prependCardidToListid({listid, cardid: card.uuid}))
}, [dispatch, listid])
const onAppendCard = useCallback(() => {
const card = D.makeRandomCard()
dispatch(C.addCard(card))
dispatch(LC.appendCardidToListid({listid, cardid: card.uuid}))
}, [dispatch, listid])
const onRemoveCard = useCallback((uuid: UUID) => () => {
dispatch(C.removeCard(uuid))
dispatch(LC.removeCardidFromListid({listid, cardid: uuid}))
}, [dispatch, listid])
return {cards, onPrependCard, onAppendCard, onRemoveCard}
}
카드 다 구현했으니 BoardList 컴포넌트 수정
//BoardList/index.tsx
import { FC, useMemo } from "react"
import { List } from "../../store/commonTypes"
import { Icon } from "../../theme/daisyui"
import { useCards } from "../../store/useCards"
import ListCard from "../ListCard"
import { Div } from "../../components"
export type BoardListProps = {
list: List
onRemoveList?: () => void
}
const BoardList: FC<BoardListProps> = ({list, onRemoveList, ...props}) => {
const {cards, onPrependCard, onAppendCard, onRemoveCard} = useCards(list.uuid)
const children = useMemo(
() =>
cards.map((card, index) => (
<ListCard key={card.uuid} card={card} onRemove={onRemoveCard(card.uuid)} />
)),
[cards, onRemoveCard]
)
return(
<Div {...props} className="p-2 m-2 border border-gray-300 rounded-lg">
<div className="flex justify-between mb-2">
<p className="w-32 text-sm font-bold underline line-clamp-1">{list.title}</p>
<div className="flex justify-between ml-2">
<Icon name="remove" className="btn-error btn-xs" onClick={onRemoveList} />
<div className="flex">
<Icon name="post_add" className="btn-success btn-xs" onClick={onPrependCard} />
<Icon name="playlist_add" className="ml-2 btn-success btn-xs" onClick={onAppendCard} />
</div>
</div>
</div>
<div className="flex flex-col p-2">{children}</div>
</Div>
)
}
export default BoardList
react-dnd의 useDrop 훅
튜플 타입 반환값에서 두 번째 멤버인 drop 함수를어든 것.
accpet는 드래그 앤 드롭 대상을 구분하는 용도로 사용.
이 drop함수를 HTML요소의 ref에 설정해서 사용 (or useRef 사용하고 drop 함수 호출)
const [, drop] = useDrop(() => ({
accept: 'card'
}))
react-dnd의 useDrag 훅
const [{isDragging}, drag] = useDrag({
type: 'card',
item: () => {
return {id, index}
},
collect: (monitor: any) => ({
isDragging: monitor.isDragging()
}),
})
ListDraggable 컴포넌트 구현
//components/ListDraggable.tsx
import { FC, useRef } from "react"
import { DivProps } from "./Div"
import { useDrag, useDrop } from "react-dnd"
import type { Identifier } from "dnd-core"
export type MoveFunc = (dragIndex: number, hoverIndex: number) => void
export type ListDraggableProps = DivProps & {
id: any
index: number
onMove: MoveFunc
}
interface DragItem {
index: number
id: string
type: string
}
export const ListDraggable: FC<ListDraggableProps> = ({
id, index, onMove, style, className, ...props
}) => {
const ref = useRef<HTMLDivElement>(null)
const [{handlerId}, drop] = useDrop<DragItem, void, {handlerId: Identifier | null}>({
accept: 'list',
collect(monitor){
return {
handlerId: monitor.getHandlerId()
}
},
hover(item: DragItem) {
if(!ref.current) return
const dragIndex = item.index
const hoverIndex = index
if(dragIndex===hoverIndex) return
onMove(dragIndex, hoverIndex)
item.index = hoverIndex
}
})
const [{isDragging}, drag] = useDrag({
type: 'list',
item: () => {
return {id, index}
},
collect: (monitor: any) => ({
isDragging: monitor.isDragging()
})
})
const opacity = isDragging ? 0 : 1
drag(drop(ref))
return(
<div ref={ref} {...props} className={[className, 'cursor-move'].join(' ')}
style={{...style, opacity}} data-handler-id={handlerId} />
)
}
onMoveList 추가해 useLists 훅 수정
import { useSelector } from "react-redux"
import { useDispatch } from "react-redux"
import { AppState } from "./AppState"
import { List } from "./commonTypes"
import { useCallback } from "react"
import * as LO from './listidOrders'
import * as L from './listEntities'
import * as C from './cardEntities'
import * as LC from './listidCardidOrders'
export const useLists = () => {
const dispatch = useDispatch()
const lists = useSelector<AppState, List[]>(({listidOrders, listEntities}) =>
listidOrders.map(uuid => listEntities[uuid])
)
const listidCardidOrders = useSelector<AppState, LC.State>(
({listidCardidOrders}) => listidCardidOrders
)
const listidOrders = useSelector<AppState, LO.State>(({listidOrders}) => listidOrders)
const onCreateList = useCallback((uuid: string, title: string) => {
const list = {uuid, title}
dispatch(LO.addListidOrders(list.uuid))
dispatch(L.addList(list))
dispatch(LC.setListidCardids({listid: list.uuid, cardids: []}))
}, [dispatch])
const onRemoveList = useCallback((listid: string) => () => {
listidCardidOrders[listid].forEach(cardid => {
dispatch(C.removeCard(cardid))
})
dispatch(LC.removeListid(listid))
dispatch(L.removeList(listid))
dispatch(LO.removeListidFromOrders(listid))
}, [dispatch, listidCardidOrders])
const onMoveList = useCallback(
(dragIndex: number, hoverIndex: number) => {
const newOrders = listidOrders.map((item, index) =>
index === dragIndex
? listidOrders[hoverIndex]
: index === hoverIndex
? listidOrders[dragIndex]
: item
)
dispatch(LO.setListidOrders(newOrders))
},
[dispatch, listidOrders]
)
return {lists, onCreateList, onRemoveList, onMoveList}
}
Board/index.tsx도 수정해 BoardList에 index와 onMoveList를 넘겨주도록 수정
react-beautiful-dnd 패키지 이해하기
DragDropContext, Droppable, Draggable 컴포넌트 제공. 컨텍스트 기반
Draggable 사용해 CardDraggable 컴포넌트 작성
import { FC, PropsWithChildren } from "react"
import { Draggable } from "react-beautiful-dnd"
export type CardDraggableProps = {
draggableId: string
index: number
}
export const CardDraggable: FC<PropsWithChildren<CardDraggableProps>> = ({
draggableId, index, children
}) => {
return(
<Draggable draggableId={draggableId} index={index}>
{provided => {
return(
<div ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}>
{children}
</div>
)
}}
</Draggable>
)
}
ListCard/index.ts의 리턴 요소들을 CardDraggable로 감싸준다
Droppable 컴포넌트로 CardDroppable 구현하기
import { FC, PropsWithChildren } from "react"
import { Droppable } from "react-beautiful-dnd"
export type CardDroppableProps = {
droppableId: string
}
export const CardDroppable: FC<PropsWithChildren<CardDroppableProps>> = ({
droppableId,
children
}) => {
return(
<Droppable droppableId={droppableId}>
{provided => (
<div
{...provided.droppableProps}
ref = {provided.innerRef}
className="flex flex-col p-2">
{children}
{provided.placeholder}
</div>
)}
</Droppable>
)
}
배열 관련 유틸리티 함수 구현
onDragEnd 속성에 설정할 콜백함수 구현을 위한 유틸리티 함수 구현
//arrayUtil.ts
//배열에서 아이템 순서 변경/제거/삽입
export const swapItemsInArray = <T>(array: T[], index1: number, index2: number) =>
array.map((item, index) =>
index === index1 ? array[index2] : index === index2 ? array[index1] : item
)
export const removeItemsInArray = <T>(array: T[], removeIndex: number) =>
array.filter((notUsed, index) => index !== removeIndex)
export const insertItemAtIndexInArray = <T>(array: T[], insertIndex: number, item: T) => {
const before = array.filter((item, index) => index < insertIndex)
const after = array.filter((item, index) => index >= insertIndex)
return [...before, item, ...after]
}
useLists 수정
import { useSelector } from "react-redux"
import { useDispatch } from "react-redux"
import { AppState } from "./AppState"
import { List } from "./commonTypes"
import { useCallback } from "react"
import * as LO from './listidOrders'
import * as L from './listEntities'
import * as C from './cardEntities'
import * as LC from './listidCardidOrders'
import * as U from '../utils'
import { DropResult } from "react-beautiful-dnd"
export const useLists = () => {
const dispatch = useDispatch()
const lists = useSelector<AppState, List[]>(({listidOrders, listEntities}) =>
listidOrders.map(uuid => listEntities[uuid])
)
const listidCardidOrders = useSelector<AppState, LC.State>(
({listidCardidOrders}) => listidCardidOrders
)
const listidOrders = useSelector<AppState, LO.State>(({listidOrders}) => listidOrders)
const onCreateList = useCallback((uuid: string, title: string) => {
const list = {uuid, title}
dispatch(LO.addListidOrders(list.uuid))
dispatch(L.addList(list))
dispatch(LC.setListidCardids({listid: list.uuid, cardids: []}))
}, [dispatch])
const onRemoveList = useCallback((listid: string) => () => {
listidCardidOrders[listid].forEach(cardid => {
dispatch(C.removeCard(cardid))
})
dispatch(LC.removeListid(listid))
dispatch(L.removeList(listid))
dispatch(LO.removeListidFromOrders(listid))
}, [dispatch, listidCardidOrders])
const onMoveList = useCallback(
(dragIndex: number, hoverIndex: number) => {
const newOrders = listidOrders.map((item, index) =>
index === dragIndex
? listidOrders[hoverIndex]
: index === hoverIndex
? listidOrders[dragIndex]
: item
)
dispatch(LO.setListidOrders(newOrders))
},
[dispatch, listidOrders]
)
const onDragEnd = useCallback(
(result: DropResult) => {
console.log('onDragEnd result', result)
const destinationListid = result.destination?.droppableId
const destinationCardIndex = result.destination?.index
if (destinationListid === undefined || destinationCardIndex === undefined) return
const sourceListid = result.source.droppableId
const sourceCardIndex = result.source.index
if(destinationListid === sourceListid){
const cardidOrders = listidCardidOrders[destinationListid]
dispatch(
LC.setListidCardids({
listid: destinationListid,
cardids: U.swapItemsInArray(
cardidOrders,
sourceCardIndex,
destinationCardIndex
)
})
)
} else {
const sourceCardidOrders = listidCardidOrders[sourceListid]
dispatch(
LC.setListidCardids({
listid: sourceListid,
cardids: U.removeItemsInArray(
sourceCardidOrders, sourceCardIndex
)
})
)
const destinationCardidOrders = listidCardidOrders[destinationListid]
dispatch(
LC.setListidCardids({
listid: destinationListid,
cardids: U.insertItemAtIndexInArray(
destinationCardidOrders,
destinationCardIndex,
result.draggableId
)
})
)
}
},
[listidCardidOrders, dispatch])
return {lists, onCreateList, onRemoveList, onMoveList, onDragEnd}
}
완성본
드래그 앤 드롭으로 리스트 또는 카드의 위치 변경 가능
백업 용도로 업로드도 해뒀다 ..
https://github.com/avocado8/redux-trello
GitHub - avocado8/redux-trello
Contribute to avocado8/redux-trello development by creating an account on GitHub.
github.com