useReducer 메소드 타입 자동으로 추론하기
useReducer
+ useContext
조합으로 하위 컨포넌트의 상태를 관리하는 방식을 종종 사용하고 있습니다.
전역 상태로 관리할 것은 아니지만, 하위 컨포넌트가 많거나 구조가 복잡한 컨포넌트를 개발할 때 편하게 사용할 수 있습니다.
다만 타입을 쓸 때 불편함을 느꼈는데요, 기존 방식에서는 Action 타입을 명시적으로 정의하고 그에 따라 reducer를 작성해야 해서 구조적으로 반복되는 코드가 많고, 액션이 많아질수록 관리가 어려워집니다.
기존 useReducer 타입스크립트 사용 방법 예시
import { useEffect, useReducer } from "react";
// State 타입 정의
type Address = {
street: string;
city: string;
postalCode: string;
};
type Contact = {
type: string;
value: string;
};
type State = {
name: string;
age: number;
address: Address;
contacts: Contact[];
};
// Action 타입 정의
type Action =
| { type: "SET_NAME"; payload: string }
| { type: "SET_AGE"; payload: number }
| { type: "SET_ADDRESS"; payload: Partial<Address> }
| { type: "ADD_CONTACT"; payload: Contact }
| { type: "UPDATE_CONTACT"; index: number; payload: Partial<Contact> }
| { type: "REMOVE_CONTACT"; index: number }
// 초기 상태 정의
const initialState: State = {
name: "",
age: 0,
address: {
street: "",
city: "",
postalCode: "",
},
contacts: [],
};
// Reducer 함수 정의
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "SET_NAME":
return { ...state, name: action.payload };
case "SET_AGE":
return { ...state, age: action.payload };
case "SET_ADDRESS":
return {
...state,
address: { ...state.address, ...action.payload },
};
case "ADD_CONTACT":
return { ...state, contacts: [...state.contacts, action.payload] };
case "UPDATE_CONTACT":
return {
...state,
contacts: state.contacts.map((contact, index) =>
index === action.index ? { ...contact, ...action.payload } : contact
),
};
case "REMOVE_CONTACT":
return {
...state,
contacts: state.contacts.filter((_, index) => index !== action.index),
};
default:
throw new Error("Unhandled action type");
}
};
// 컴포넌트 정의
const PersonalInfoForm = () => {
const [state, dispatch] = useReducer(reducer, initialState);
개인적으로는 구현과 타입이 분리되어 있는 구조를 선호하지 않습니다. 타입 정의와 로직 구현이 이분화되면 코드 규모가 커질수록 관리하기 어려워집니다.
useReducer 메소드 타입 추론 유틸함수 만들기
이에 따라 타입 정의를 따로 선언하는게 아니라 메소드의 타입을 추론할 수 있는 구조를 만들어보았습니다.
상태 타입 State를 명시적으로 입력받고, 상태를 변경하는 메소드들을 객체로 전달받습니다. 이 메소드 객체로부터 액션 타입을 자동 생성하며, payload의 유무에 따라 액션 타입을 구분합니다.
export const reducerFactory =
<State extends object>() =>
<
T extends Record<
string,
(state: State, payload?: any) => State | Partial<State>
>
>(
functions: T
) => {
type Action = {
[K in keyof T]: Parameters<T[K]>[1] extends undefined
? { type: K } // payload가 없으면 payload 키를 제외
: { type: K; payload: Parameters<T[K]>[1] }; // payload가 있으면 payload 키를 필수로 추가
}[keyof T];
return (state: State, action: Action) => {
const handler = functions[action.type];
if ("payload" in action) {
return {
...state,
...handler(state, action.payload),
} as State;
} else {
return {
...state,
...handler(state),
} as State;
}
};
};
export default reducerFactory;
이를 구현하기 위해서는 2가지의 제네릭 타입을 인자로 받아야 합니다. 상태의 타입과 메소드의 타입이 필요한데요, 상태의 타입은 개발자가 지정하고, 메소드의 타입은 자동으로 추론되도록 해야 합니다.
타입스크립트에서는 2개의 제네릭 타입을 받을 때 하나는 명시적으로 입력받고 하나는 추론하는게 불가능해서 커링을 사용했습니다.
이를 통해 reducerFactory<StateType>(method)
형태로 호출 해 메소드를 객체로 묶어 인자로 넘길 수 있습니다. 메소드의 타입은 첫번째 인자에 타입을, 두번째 인자에 메소드에서 사용할 파라미터로 입력받도록 고정시켰습니다. 복수의 인자로 입력을 받아야 할 때는 객체나 배열로 입력받을 수 있습니다.
함수 내부에서는 메소드의 이름 (keyof T
)을 useReducer에서 사용할 type
으로 만들고, 함수의 파라미터 타입을 추론합니다. payload가 있는 경우에는 payload에 대한 타입을 추가해줍니다.
이후 useReducer
훅의 인자로 넣을 함수를 만들어주는데요, 내부적으로 현재 상태와 메소드의 실행 결과를 합쳐서 메소드에서 반환할 키만 반환해도 상태를 업데이트 할 수 있도록 설계했습니다.
reducerFactory 사용 예시
import { useEffect, useReducer } from "react";
import { reducerFactory } from "./f";
// State 타입 정의
type Address = {
street: string;
city: string;
postalCode: string;
};
type Contact = {
type: string;
value: string;
};
type State = {
name: string;
age: number;
address: Address;
contacts: Contact[];
};
// 초기 상태 정의
const initialState: State = {
name: "",
age: 0,
address: {
street: "",
city: "",
postalCode: "",
},
contacts: [],
};
const reducer = reducerFactory<State>()({
SET_NAME: (state, payload: string) => {
return { name: payload };
},
SET_AGE: (state, payload: number) => {
return { age: payload };
},
SET_ADDRESS: (state, payload: Partial<State["address"]>) => {
return {
address: {
...state.address,
...payload,
},
};
},
ADD_CONTACT: (state, payload: Contact) => {
return {
contacts: [...state.contacts, payload],
};
},
UPDATE_CONTACT: (
state,
payload: {
index: number;
contact: Partial<Contact>;
}
) => {
return {
contacts: state.contacts.map((contact, index) =>
index === index ? { ...contact, ...payload } : contact
),
};
},
REMOVE_CONTACT: (state, index: number) => {
return {
contacts: state.contacts.filter((_, index) => index !== index),
};
},
});
// 컴포넌트 정의
const PersonalInfoForm = () => {
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
console.log(state);
}, [state]);
return (
<div>
<h2>Personal Information</h2>
<label>
Name:
<input
type="text"
value={state.name}
onChange={(e) =>
dispatch({ type: "SET_NAME", payload: e.target.value })
}
/>
</label>
<label>
Age:
<input
type="number"
value={state.age}
onChange={(e) =>
dispatch({
type: "SET_AGE",
payload: parseInt(e.target.value, 10) || 0,
})
}
/>
</label>
<h3>Address</h3>
<label>
Street:
<input
type="text"
value={state.address.street}
onChange={(e) =>
dispatch({
type: "SET_ADDRESS",
payload: { street: e.target.value },
})
}
/>
</label>
<label>
City:
<input
type="text"
value={state.address.city}
onChange={(e) =>
dispatch({ type: "SET_ADDRESS", payload: { city: e.target.value } })
}
/>
</label>
<label>
Postal Code:
<input
type="text"
value={state.address.postalCode}
onChange={(e) =>
dispatch({
type: "SET_ADDRESS",
payload: { postalCode: e.target.value },
})
}
/>
</label>
<h3>Contacts</h3>
{state.contacts.map((contact, index) => (
<div key={index}>
<input
type="text"
placeholder="Type"
value={contact.type}
onChange={(e) =>
dispatch({
type: "UPDATE_CONTACT",
payload: {
index,
contact: {
type: e.target.value,
},
},
})
}
/>
<input
type="text"
placeholder="Value"
value={contact.value}
onChange={(e) =>
dispatch({
type: "UPDATE_CONTACT",
payload: {
index,
contact: {
value: e.target.value,
},
},
})
}
/>
<button
onClick={() => dispatch({ type: "REMOVE_CONTACT", payload: index })}
>
Remove
</button>
</div>
))}
<button
onClick={() =>
dispatch({ type: "ADD_CONTACT", payload: { type: "", value: "" } })
}
>
Add Contact
</button>
</div>
);
};
export default PersonalInfoForm;
기존 코드와 동일한 로직이며, 타입을 직접 선언하지 않고 reducerFactory 함수에 인자를 하나 받는 형태로 수정했습니다.
의도한 대로 dispatch
함수 실행 시, 각 메소드에 맞는 인자의 타입을 추론할 수 있습니다.
dispatch 타입 추론
만들어진 reducer 함수를 사용하는 dispatch
함수의 타입을 추론하기 위해서는 Dispatch<ReducerAction<T>>
로 사용할 수 있습니다. context api에 dispatch 함수를 넘길때 유용합니다.
const reducer = reducerFactory<State>()({
...
})
import { Dispatch, ReducerAction } from "react";
type DispatchFn = Dispatch<ReducerAction<typeof reducer>>;