지난 글에서는 fetch의 POST 메서드를 활용해 새로운 데이터를 리스트에 추가하는 기능을 구현했다.
다음 단계는 수정하기(PATCH) 작업이다.
현재 프로젝트에서 수정 기능은 전체 데이터 수정하기와 개별 데이터 수정하기 두 가지로 나뉘어 있다.
전체 데이터 수정하기와 개별 데이터 수정하기 두 가지 기능이 있기 때문에, 먼저 대략적인 코드 설계를 세워 둔 뒤 작업을 진행하기로 했다.
1. 첫 로드 시 users 데이터를 Provider에서 관리하는 데이터에 저장
2. UsersItem으로 전달하여 input value에 연결
3. onChange 이벤트로 데이터 수정
4. 수정된 유저의 데이터만 골라내기
5-1. 전체 데이터 수정 : Users의 전체 수정하기 버튼 클릭 시 Promise.all로 여러 PATCH 요청을 동시에 처리
5-2. 개별 데이터 수정 : UsersItem의 수정하기 버튼 클릭 시 단일 PATCH 요청 실행
6. 결과값을 다시 렌더링하여 화면에 반영
대략적인 설계는 위와 같고, 구현을 진행하면서 필요에 따라 일부 구조를 조정할 수 있다.
가장 먼저 해야 할 일은, 수정하기 버튼을 눌렀을 때 현재 화면에 보이는 텍스트가 그대로 input의 value로 들어가도록 만드는 것이다.

최종적으로 값을 연결해야 하는 UsersItem의 input들을 먼저 보자.
<input type="text" name={`first_name_${id}`} placeholder="first name" />
<input type="text" name={`last_name_${id}`} placeholder="last name" />
<input type="text" name={`email_${id}`} placeholder="email" />
<input id={`userItem_${id}`} type="file" accept="image/*" />
users 데이터를 map으로 순회하면서 화면에 렌더링하고 있기 때문에, 각 input의 name을 id와 조합해서 유니크하게 만들었다.
따라서 변경된 유저 데이터를 관리할 때도, key 값이 이 name 패턴과 일치하도록 맞추는 편이 이후 로직에서 다루기 쉽다.
먼저 타입을 정해 보자.
key 값은 input의 name과 동일한 문자열이어야 하고, 실제 값의 타입은 User의 각 필드 타입과 같아야 한다.
// src/types/users.ts
export type User = {
avatar: string
email: string
first_name: string
id: number
last_name: string
}
type UserKeys = keyof User
export type EditableUserKey = Exclude<UserKeys, 'id'>
export type PersonalEditableUserValue = { [K in EditableUserKey as `${K}_${number}`]: User[K] }
export type PersonalUserValue = PersonalEditableUserValue & { isModify: boolean; id: User['id'] }
먼저 keyof User로 User가 가진 키들의 유니온 타입을 뽑아 UserKeys로 두고, 여기서 id를 제외한 것을 EditableUserKey로 만들었다.
그 다음, K in EditableUserKey를 순회하면서 각 키에 대해 ${K}_${number} 형태의 문자열 키를 만들고, 그 값 타입은 User[K]와 동일하게 매핑했다.
추가로, 해당 데이터가 수정되었는지 여부를 표시하기 위해 isModify 플래그와 id를 함께 가지는 PersonalUserValue 타입을 정의했다.
현재 프로젝트 구조에서 유저들의 데이터(users)는 UsersContainer에서 불러와 화면에 렌더링하고 있다.
input value에도 동일한 값을 연결하려면, 이 users 데이터를 UsersProvider로 넘겨서 Provider 내부 상태로 다시 가공할 필요가 있다.
// src/features/users/containers/UsersContainer.tsx
export default function UsersContainer() {
const { users } = useUsersQuery()
return (
<UsersProvider users={users} onCreate={createUsers}>
// ...
</UsersProvider>
)
}
전달받은 users 데이터는, input의 onChange로 변경되기 전 초기 값으로 따로 저장해 둘 것이다. (initialBuiltAllUsersValue)
그래야 나중에 변경된 데이터와 비교해서, 어떤 유저가 실제로 수정되었는지 필터링할 수 있다.
// src/features/users/context/UsersProvider.tsx
const toPersonalKey = <K extends EditableUserKey>(key: K, id: User['id']) => `${key}_${id}` as `${K}_${number}`
export default function UsersProvider({
users,
// ...
}: UsersProviderProps) {
const buildUsersData = useCallback((data: User[]) => {
return data.reduce<BuiltAllUsersValue>(
(acc, cur) => {
const personalValue = {
[toPersonalKey('first_name', cur.id)]: cur.first_name,
[toPersonalKey('last_name', cur.id)]: cur.last_name,
[toPersonalKey('email', cur.id)]: cur.email,
[toPersonalKey('avatar', cur.id)]: cur.avatar ?? '',
}
acc[cur.id] = {
...personalValue,
id: cur.id,
isModify: false,
}
return acc
},
{}, // 초기값: acc의 시작 값
)
}, [])
const initialBuiltAllUsersValue = useMemo(() => buildUsersData(users), [buildUsersData, users])
const [builtAllUsersValue, setBuiltAllUsersValue] = useState<BuiltAllUsersValue>(initialBuiltAllUsersValue)
}
UsersProvider로 전달된 users를 reduce로 가공해서, 각 유저의 id를 1차 key로, 그 안에 first_name, last_name, email, avatar를 필드명_id 형태의 key로 담은 객체 구조를 만든다.
여기에 id와 isModify를 함께 넣어 초기값인 initialBuiltAllUsersValue를 만든다.
이와 동일한 구조를 상태로 관리하는 builtAllUsersValue도 만들어 두고,
input의 value에는 builtAllUsersValue 값을 연결한다.
나중에 PATCH 요청을 보내기 전에, initialBuiltAllUsersValue와 builtAllUsersValue를 비교해서 실제로 수정된 값들만 골라낼 계획이다.

// src/features/users/components/UsersItem.tsx
export default function UsersItem({ profileSrc, firstName, lastName, email, id }: UsersItem) {
const {builtAllUsersValue} = useUsersState()
const userInputValues = builtAllUsersValue[id]
const firstNameValue = userInputValues ? userInputValues[`first_name_${id}`] : ''
const lastNameValue = userInputValues ? userInputValues[`last_name_${id}`] : ''
const emailValue = userInputValues ? userInputValues[`email_${id}`] : ''
const avatarSrc = (userInputValues ? userInputValues[`avatar_${id}`] : '')
return (
<li className="userItem">
// 데이터 보여지는 영역
<>
<span className="userItem__name">
{firstName} {lastName}
</span>
<span className="userItem__email">{email}</span>
</>
// 에디터 영역
<div className="userItem__editer">
<input
type="text"
name={`first_name_${id}`}
placeholder="first name"
value={firstNameValue}
/>
<input
type="text"
name={`last_name_${id}`}
placeholder="last name"
value={lastNameValue}
/>
<input
type="text"
name={`email_${id}`}
placeholder="email"
value={emailValue}
/>
</li>
)
}
builtAllUsersValue는 모든 유저의 데이터를 담고 있으므로, 먼저 id로 해당 유저의 데이터를 찾고,
그 안에서 first_name_${id}, last_name_${id}, email_${id}, avatar_${id} 값을 꺼내 input의 value와 이미지 소스로 연결했다.

수정하기 버튼을 눌렀을 때, 각 유저에 맞는 데이터가 input value로 잘 연결되는 것을 확인했다.
여기까지가 각 유저별로 수정 폼에 초기값을 주입하는 단계의 완료라고 볼 수 있다.
이제 input의 onChange 이벤트로 값이 변경되었을 때,
builtAllUsersValue에 반영되는 로직을 구현한다.
먼저 UsersProvider에서 onChange 함수를 만들어 UsersItem으로 전달하고, 각 input의 onChange에 연결한다.
// src/features/users/context/UsersProvider.tsx
const updateBuiltUserData = useCallback(
(
id: User['id'],
name: PersonalEditableUserKey,
value: PersonalEditableUserValue[PersonalEditableUserKey],
) => {
setBuiltAllUsersValue((prev) => {
const target = prev[id]
if (!target) return prev
const nextEntry: PersonalUserValue = {
...target,
[name]: value,
}
const originalUser = users.find((user) => user.id === id)
if (originalUser) {
const isModify =
nextEntry[`first_name_${id}`] !== originalUser.first_name ||
nextEntry[`last_name_${id}`] !== originalUser.last_name ||
nextEntry[`email_${id}`] !== originalUser.email ||
nextEntry[`avatar_${id}`] !== originalUser.avatar
return {
...prev,
[id]: {
...nextEntry,
isModify,
},
}
}
return {
...prev,
[id]: {
...nextEntry,
isModify: true,
},
}
})
},
[users],
)
const onChangeUserData = useCallback<OnChangeUserData>(
(e, id) => {
const { name, value } = e.target
updateBuiltUserData(id, name, value.trim())
},
[updateBuiltUserData],
)
onChangeUserData는 이벤트 객체와 id를 받아서,
updateBuiltUserData에 넘겨 주고, 여기서 builtAllUsersValue의 해당 유저 데이터를 업데이트한다.
업데이트 이후에는, 원래 users 배열에서 해당 유저를 찾아 first_name, last_name, email, avatar와 비교해,
하나라도 변경되었다면 isModify를 true로, 모두 원래 값과 같다면 false로 설정한다.
이렇게 해서 수정된 데이터를 담고 있는 builtAllUsersValue와, 어느 유저가 실제로 수정되었는지를 표시하는 isModify 플래그를 함께 관리할 수 있게 된다.
// src/features/users/components/UsersItem.tsx
export default function UsersItem({
//...
}: UsersItem) {
const { onChangeUserData } = useUsersActions()
const handleChangeUserData = (
e: ChangeEvent<HTMLInputElement & { name: PersonalEditableUserKey }>,
) => onChangeUserData(e, id)
return (
<li className="userItem">
<div className="userItem__editer">
<input
type="text"
name={`first_name_${id}`}
placeholder="first name"
value={firstNameValue}
onChange={handleChangeUserData}
/>
<input
type="text"
name={`last_name_${id}`}
placeholder="last name"
value={lastNameValue}
onChange={handleChangeUserData}
/>
<input
type="text"
name={`email_${id}`}
placeholder="email"
value={emailValue}
onChange={handleChangeUserData}
/>
</li>
)
}
// src/types/users.ts
export type PersonalEditableUserKey = keyof PersonalEditableUserValue
// type PersonalEditableUserKey = `avatar_${number}` | `email_${number}` | `first_name_${number}` | `last_name_${number}`
onChangeUserData에서 name을 그냥 string으로 받으면,
updateBuiltUserData에서 기대하는 ${K}_${number} 패턴(PersonalEditableUserKey)과 맞지 않아 타입 에러가 발생한다.
그래서 UsersItem 쪽에서 ChangeEvent<HTMLInputElement & { name: PersonalEditableUserKey }>처럼
name을 미리 PersonalEditableUserKey로 좁혀서 넘겨 주도록 했다.
이제 PATCH 요청을 보내기 전에,
어떤 유저의 어떤 값이 수정되었는지를 비교해서 필요한 데이터만 골라야 한다.
// src/features/users/context/UsersProvider.tsx
const filterModifiedData = useCallback(() => {
const usersArray = Object.values(builtAllUsersValueRef.current)
const modifiedData = usersArray.filter(({ isModify }) => isModify)
const filteredModifiedData = modifiedData.reduce((acc, user) => {
const original = initialBuiltAllUsersValue[user.id]
const changed = EDITABLE_USER_KEYS.reduce<EditableUserFormObject>((fieldAcc, key) => {
const personalKey: PersonalEditableUserKey = `${key}_${user.id}`
if (!original || user[personalKey] !== original[personalKey]) {
fieldAcc[key] = user[personalKey]
}
return fieldAcc
}, {})
if (Object.keys(changed).length) acc[user.id] = changed
return acc
}, {} as FilteredModifiedAllData)
return filteredModifiedData
}, [initialBuiltAllUsersValue])
builtAllUsersValue 객체를 Object.values로 배열로 변환한 뒤,
isModify가 true인 유저만 골라 modifiedData로 만든다.
builtAllUsersValue를 그대로 의존성 배열에 넣으면 콜백이 자주 재생성되므로,
useRef로 최신 값을 보관한 builtAllUsersValueRef.current를 사용해 불필요한 콜백 재생성을 줄였다.
그 다음, modifiedData를 reduce로 돌면서 각 유저에 대해 다음을 수행한다.
initialBuiltAllUsersValue에서 해당 유저의 원본 데이터를 찾고EDITABLE_USER_KEYS(email, first_name, last_name, avatar)를 기준으로changed 객체에 쌓는다.최종적으로 filterModifiedData는
{ [id]: { 수정된 필드만 모은 객체 } } 형태의 데이터를 반환한다.

// src/api/users.api.ts
// 개별 수정 API
export const patchUserApi = async (id: User['id'], payload: PayloadModifiedUser) => {
const response = await fetch(`https://reqres.in/api/users/${id}`, {
method: 'PATCH',
headers: {
'x-api-key': 'reqres_34b210b936844955a8b80641c7073e29',
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
if (!response.ok) throw Error('유저 데이터를 수정할 수 없습니다.')
const result: ApiResultModifiedUser = await response.json()
return result
}
// 전체 수정 API
export const patchAllUsersApi = async (data: PayloadAllModifiedUsers) => {
const responses = await Promise.all(
data.map(({ id, payload }) =>
fetch(`https://reqres.in/api/users/${id}`, {
method: 'PATCH',
headers: {
'x-api-key': 'reqres_34b210b936844955a8b80641c7073e29',
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
}),
),
)
const isError = responses.some((res) => !res.ok)
if (isError) throw Error('유저 데이터를 수정할 수 없습니다.')
const results: ApiResultAllModifiedUsers = await Promise.all(
responses.map((res, idx) =>
res.json().then((body) => ({ id: data[idx].id, result: { ...body } })),
),
)
return results
}
PATCH 작업을 하기 전에, API에서 어떤 형태의 데이터를 주고받을지부터 정리했다.
개별 수정인 patchUserApi는 id와 payload를 받아 단일 유저의 PATCH를 수행하고,
전체 수정인 patchAllUsersApi는 여러 유저의 id와 payload를 묶은 배열을 받아 Promise.all로 여러 PATCH 요청을 동시에 보낸다.
export type PayloadAllModifiedUsers = { id: User['id']; payload: EditableUserFormObject }[]
PayloadAllModifiedUsers는 { id, payload } 객체의 배열 형태이며,
patchAllUsersApi에서 map으로 순회하며 각 유저에 대해 PATCH 요청을 보내는 데 사용된다.
// src/hooks/useUsersQuery.ts
export function useUsersQuery() {
const [users, setUsers] = useState<User[]>([])
const modifyUser = useCallback(async (id: number, payload: PayloadModifiedUser) => {
try {
const result = await patchUserApi(id, payload)
if (!result) return
const { updatedAt: _, ...rest } = result
setUsers((prev) =>
prev.map((user) =>
user.id === id
? {
...user,
...rest,
}
: user,
),
)
void _
} catch (err) {
console.error(err)
if (err instanceof Error) setError(err.message)
}
}, [])
const modifyAllUsers = useCallback(async (data: PayloadAllModifiedUsers) => {
try {
const results = await patchAllUsersApi(data)
setUsers((prev) => {
const resultMap = new Map(results.map(({ id, result }) => [id, result]))
return prev.map((user) => {
const patched = resultMap.get(user.id)
if (!patched) return user
const { updatedAt: _, ...rest } = patched
void _
return { ...user, ...rest }
})
})
} catch (err) {
console.error(err)
if (err instanceof Error) setError(err.message)
}
}, [])
return { users, modifyUser, modifyAllUsers }
}
PATCH 응답에는 updatedAt 같은 메타 정보가 포함되지만,
User 타입에는 필요 없으므로 구조분해 할당으로 꺼내서 버리고 나머지만 setUsers에 반영했다.
전체 수정인 modifyAllUsers는 응답 results를 [id, result] 배열로 Map에 담아 두고,
기존 users 배열을 돌면서 id가 같은 유저에 대해서만 패치된 값을 덮어쓴다.
이렇게 만든 modifyUser와 modifyAllUsers는 UsersContainer에서 받아
UsersProvider에 onModify, onAllModify props로 넘겨 사용한다.
// src/features/users/containers/UsersContainer.tsx
export default function UsersContainer() {
const { users, modifyUser, modifyAllUsers } =
useUsersQuery()
return (
<UsersProvider
users={users}
onModify={modifyUser}
onAllModify={modifyAllUsers}
>
// ...
</UsersProvider>
)
}
앞에서 만든 filterModifiedData는 수정된 모든 유저의 데이터를 담고 있다.
단체 수정을 구현하기 전에, 먼저 개별 수정 기능을 구현해 보자.
PATCH를 하기 위해 필요한 것은 결국 id와 payload 두 가지다.
filterModifiedData가 반환하는 객체는 id를 key로 가지고 있기 때문에,
해당 id로 접근하면 바로 payload 형태의 데이터를 얻을 수 있다.
// src/features/users/context/UsersProvider.tsx
const onItemEditor = useCallback(
async ({ id, isShowEditor, isPatch = false }: OnItemEditor) => {
// 수정완료(PATCH) : isPatch
if (isPatch) {
if (isPatchingRef.current !== null) return
const filteredModifiedData = filterModifiedData()
const payload = filteredModifiedData[id]
if (!payload) {
alert('수정된 내역이 없습니다.')
return
}
const hasEmpty = hasEmptyRequiredField(payload)
if (hasEmpty) {
alert('이메일, 이름, 성은 빈값으로 수정할 수 없습니다.')
return
}
try {
setIsPatching(id)
await onModify(id, payload)
} catch (err) {
console.error(err)
} finally {
setIsPatching(null)
}
}
// ... 이하 생략
},
[filterModifiedData, onModify],
)
수정된 값이 없다면 payload가 undefined이므로,
이 경우에는 PATCH를 보내지 않고 경고(alert)를 띄운 뒤 함수를 종료한다.
또한 필수 입력값인 first_name, last_name, email에 대해,
빈 문자열로 수정하려는 경우를 막기 위해 별도의 검증 함수를 사용했다.
const hasEmptyRequiredField = (data: EditableUserFormObject) => {
const hasEmpty = REQUIRED_USER_KEYS.some((key) => {
return data[key] !== undefined && data[key].trim() === ''
})
return hasEmpty
}


값이 정상적으로 수정되고 화면에도 반영되는 것을 확인했다.
// src/features/users/context/UsersProvider.tsx
const onAllEditor = useCallback(
async ({ isShowEditor, isPatch = false }: OnAllEditor) => {
// 수정완료(PATCH) : isPatch
if (isPatch) {
if (isPatchingRef.current !== null) return
const filteredModifiedData = filterModifiedData()
const data = Object.entries(filteredModifiedData).map(([id, payload]) => {
const numId = Number(id)
return { id: numId, payload }
})
if (data.length === 0) {
alert('수정된 내역이 없습니다.')
return
}
const hasEmpty = Object.values(filteredModifiedData).some(hasEmptyRequiredField)
if (hasEmpty) {
alert('이메일, 이름, 성은 빈값으로 수정할 수 없습니다.')
return
}
try {
setIsPatching('all')
await onAllModify(data)
} catch (err) {
console.error(err)
} finally {
setIsPatching(null)
}
}
// ... 이하 생략
},
[filterModifiedData, onAllModify],
)
전체 수정하기는, filterModifiedData 결과를 Object.entries로 풀어
{ id: number, payload } 배열로 변환한 뒤,
아까 만든 modifyAllUsers(patchAllUsersApi)에 넘겨 한 번에 PATCH를 수행한다.
수정된 데이터가 하나도 없다면 경고를 띄우고 종료하고,
필수 필드에 빈 문자열이 있는 경우에도 PATCH를 보내지 않는다.


전체 수정하기 또한 정상적으로 반영되는 것을 확인했다.
PATCH 초기 작업은 아래 Git Branch 링크에서 확인할 수 있으며,
현재는 Main Branch에서 코드 리펙토링으로 많이 수정되었다.
feature/fetchPatch Branch 바로가기
여기까지 수정하기 기능을 구현했다. 실제로 가장 작업량이 많은 부분은 거의 끝났다고 볼 수 있다.
수정하기 기능은 완료했으니, 다음 단계는 삭제하기(DELETE) 작업이다.
삭제 기능은 선택된 데이터 삭제하기와 개별 데이터 삭제하기로 나뉘어 있으며,
다음 게시물에서 DELETE 로직을 구현해 볼 예정이다.