useOptimistic

김동현·2026년 3월 17일

useOptimistic

소개

useOptimistic은 UI를 낙관적으로 업데이트할 수 있게 해주는 React Hook이에요.

const [optimisticState, setOptimistic] = useOptimistic(value, reducer?);

레퍼런스 {/reference/}

useOptimistic(value, reducer?) {/useoptimistic/}

낙관적 상태(optimistic state)를 만들려면 컴포넌트의 최상위 레벨에서 useOptimistic을 호출하세요.

import { useOptimistic } from 'react';

function MyComponent({name, todos}) {
  const [optimisticAge, setOptimisticAge] = useOptimistic(28);
  const [optimisticName, setOptimisticName] = useOptimistic(name);
  const [optimisticTodos, setOptimisticTodos] = useOptimistic(todos, todoReducer);
  // ...
}

아래에서 더 많은 예제를 확인하세요.

매개변수 (Parameters) {/parameters/}

  • value: 대기 중인 Action이 없을 때 반환되는 값이에요.
  • 선택적 reducer(currentState, action): 낙관적 상태가 어떻게 업데이트되는지를 지정하는 리듀서 함수예요. 순수 함수여야 하고, 현재 상태와 리듀서 액션 인자를 받아서 다음 낙관적 상태를 반환해야 해요.

반환값 (Returns) {/returns/}

useOptimistic은 정확히 두 개의 값을 가진 배열을 반환해요:

  1. optimisticState: 현재 낙관적 상태예요. Action이 대기 중이 아니면 value와 같고, Action이 대기 중이면 reducer가 반환한 상태(또는 reducer가 제공되지 않았다면 set 함수에 전달된 값)와 같아요.
  2. set 함수: Action 내에서 낙관적 상태를 다른 값으로 업데이트할 수 있게 해줘요.

set 함수, 예를 들어 setOptimistic(optimisticState) {/setoptimistic/}

useOptimistic이 반환하는 set 함수를 사용하면 Action 동안 상태를 업데이트할 수 있어요. 다음 상태를 직접 전달하거나, 이전 상태로부터 계산하는 함수를 전달할 수 있어요:

const [optimisticLike, setOptimisticLike] = useOptimistic(false);
const [optimisticSubs, setOptimisticSubs] = useOptimistic(subs);

function handleClick() {
  startTransition(async () => {
    setOptimisticLike(true);
    setOptimisticSubs(a => a + 1);
    await saveChanges();
  });
}

매개변수 (Parameters) {/setoptimistic-parameters/}

  • optimisticState: Action 동안 낙관적 상태가 되길 원하는 값이에요. useOptimisticreducer를 제공했다면, 이 값이 리듀서의 두 번째 인자로 전달될 거예요. 어떤 타입의 값이든 될 수 있어요.
    • optimisticState로 함수를 전달하면, 업데이터 함수로 취급돼요. 순수 함수여야 하고, 대기 중인 상태를 유일한 인자로 받아서 다음 낙관적 상태를 반환해야 해요. React는 업데이터 함수를 큐에 넣고 컴포넌트를 리렌더링할 거예요. 다음 렌더링 중에 React는 useState 업데이터와 유사하게 큐에 있는 업데이터들을 이전 상태에 적용해서 다음 상태를 계산해요.

반환값 (Returns) {/setoptimistic-returns/}

set 함수는 반환값이 없어요.

주의사항 (Caveats) {/setoptimistic-caveats/}

  • set 함수는 반드시 Action 내에서 호출해야 해요. Action 밖에서 setter를 호출하면, React가 경고를 표시하고 낙관적 상태가 잠깐 렌더링될 거예요.

낙관적 상태가 작동하는 방식 {/how-optimistic-state-works/}

useOptimistic을 사용하면 Action이 진행되는 동안 임시 값을 보여줄 수 있어요:

const [value, setValue] = useState('a');
const [optimistic, setOptimistic] = useOptimistic(value);

startTransition(async () => {
  setOptimistic('b');
  const newValue = await saveChanges('b');
  setValue(newValue);
});

Action 내에서 setter가 호출되면, useOptimistic은 Action이 진행되는 동안 해당 상태를 보여주기 위해 리렌더링을 트리거해요. 그렇지 않으면, useOptimistic에 전달된 value가 반환돼요.

이 상태를 "낙관적(optimistic)"이라고 부르는 이유는 실제로는 Action을 완료하는 데 시간이 걸리지만, 사용자에게 즉시 Action 수행 결과를 보여주기 위해 사용되기 때문이에요.

업데이트 흐름

  1. 즉시 업데이트: setOptimistic('b')가 호출되면, React는 즉시 'b'로 렌더링해요.

  2. (선택적) Action에서 await: Action에서 await을 사용하면, React는 계속 'b'를 보여줘요.

  3. Transition 예약됨: setValue(newValue)가 실제 상태에 대한 업데이트를 예약해요.

  4. (선택적) Suspense 대기: newValue가 suspend되면, React는 계속 'b'를 보여줘요.

  5. 단일 렌더 커밋: 마지막으로, newValuevalue에 커밋되고 optimistic도 같이 업데이트돼요.

낙관적 상태를 "지우기" 위한 추가 렌더링은 없어요. Transition이 완료되면 낙관적 상태와 실제 상태가 같은 렌더링에서 수렴해요.

낙관적 상태는 임시적이에요 {/optimistic-state-is-temporary/}

낙관적 상태는 Action이 진행되는 동안만 렌더링되고, 그렇지 않으면 value가 렌더링돼요.

만약 saveChanges'c'를 반환했다면, valueoptimistic 모두 'c'가 될 거예요, 'b'가 아니라요.

최종 상태가 결정되는 방식

useOptimisticvalue 인자는 Action이 끝난 후 무엇이 표시될지를 결정해요. 이게 어떻게 작동하는지는 사용하는 패턴에 따라 달라져요:

  • 하드코딩된 값 useOptimistic(false) 같은 경우: Action 이후에도 state는 여전히 false이므로, UI는 false를 보여줘요. 이건 항상 false에서 시작하는 대기 상태에 유용해요.

  • 전달된 props나 state useOptimistic(isLiked) 같은 경우: Action 동안 부모가 isLiked를 업데이트하면, Action이 완료된 후 새 값이 사용돼요. 이렇게 UI가 Action의 결과를 반영하죠.

  • 리듀서 패턴 useOptimistic(items, fn) 같은 경우: Action이 대기 중인 동안 items가 변경되면, React는 새로운 itemsreducer를 다시 실행해서 상태를 재계산해요. 이렇게 하면 낙관적으로 추가한 항목이 최신 데이터 위에 유지돼요.

Action이 실패하면 어떻게 되나요?

Action이 에러를 던지면, Transition은 여전히 종료되고, React는 현재 value로 렌더링해요. 부모는 일반적으로 성공 시에만 value를 업데이트하므로, 실패는 value가 변경되지 않았음을 의미하고, UI는 낙관적 업데이트 전에 보여주던 것을 보여줘요. 에러를 catch해서 사용자에게 메시지를 표시할 수 있어요.


사용법 (Usage) {/usage/}

컴포넌트에 낙관적 상태 추가하기 {/adding-optimistic-state-to-a-component/}

하나 이상의 낙관적 상태를 선언하려면 컴포넌트의 최상위 레벨에서 useOptimistic을 호출하세요.

import { useOptimistic } from 'react';

function MyComponent({age, name, todos}) {
  const [optimisticAge, setOptimisticAge] = useOptimistic(age);
  const [optimisticName, setOptimisticName] = useOptimistic(name);
  const [optimisticTodos, setOptimisticTodos] = useOptimistic(todos, reducer);
  // ...

useOptimistic은 정확히 두 개의 항목이 있는 배열을 반환해요:

  1. 처음에는 제공된 value로 설정되는 낙관적 상태.
  2. Action 내에서 상태를 임시로 변경할 수 있게 해주는 set 함수.
    • reducer가 제공되면, 낙관적 상태를 반환하기 전에 실행될 거예요.

낙관적 상태를 사용하려면, Action 내에서 set 함수를 호출하세요.

Action은 startTransition 내에서 호출되는 함수예요:

function onAgeChange(e) {
  startTransition(async () => {
    setOptimisticAge(42);
    const newAge = await postAge(42);
    setAge(newAge);
  });
}

React는 age가 현재 나이로 유지되는 동안 먼저 낙관적 상태 42를 렌더링할 거예요. Action은 POST를 기다린 다음, ageoptimisticAge 모두에 대해 newAge를 렌더링해요.

자세한 내용은 낙관적 상태가 작동하는 방식을 참고하세요.

Action props를 사용할 때는 startTransition 없이 set 함수를 호출할 수 있어요:

async function submitAction() {
  setOptimisticName('Taylor');
  await updateName('Taylor');
}

이게 작동하는 이유는 Action props가 이미 startTransition 내에서 호출되기 때문이에요.

예제는 Action props에서 낙관적 상태 사용하기를 참고하세요.


Action props에서 낙관적 상태 사용하기 {/using-optimistic-state-in-action-props/}

Action prop에서는 startTransition 없이 낙관적 setter를 직접 호출할 수 있어요.

이 예제는 <form>submitAction prop 내에서 낙관적 상태를 설정해요:

import { useState, startTransition } from 'react';
import EditName from './EditName';

export default function App() {
  const [name, setName] = useState('Alice');
  
  return <EditName name={name} action={setName} />;
}
import { useOptimistic, startTransition } from 'react';
import { updateName } from './actions.js';

export default function EditName({ name, action }) {
  const [optimisticName, setOptimisticName] = useOptimistic(name);

  async function submitAction(formData) {
    const newName = formData.get('name');
    setOptimisticName(newName);
    
    const updatedName = await updateName(newName);
    startTransition(() => {
      action(updatedName);
    })
  }

  return (
    <form action={submitAction}>
      <p>Your name is: {optimisticName}</p>
      <p>
        <label>Change it: </label>
        <input
          type="text"
          name="name"
          disabled={name !== optimisticName}
        />
      </p>
    </form>
  );
}
export async function updateName(name) {
  await new Promise((res) => setTimeout(res, 1000));
  return name;
}

이 예제에서 사용자가 폼을 제출하면, 서버 요청이 진행되는 동안 optimisticName이 즉시 newName을 낙관적으로 보여주도록 업데이트돼요. 요청이 완료되면, nameoptimisticName이 응답의 실제 updatedName으로 렌더링돼요.

startTransition이 필요 없나요? {/why-doesnt-this-need-starttransition/}

관례적으로, startTransition 내에서 호출되는 props는 "Action"으로 명명돼요.

submitAction이 "Action"으로 명명되어 있으므로, 이미 startTransition 내에서 호출된다는 걸 알 수 있어요.

Action prop 패턴에 대한 자세한 내용은 컴포넌트에서 action prop 노출하기를 참고하세요.


Action props에 낙관적 상태 추가하기 {/adding-optimistic-state-to-action-props/}

Action prop을 만들 때, 즉각적인 피드백을 보여주기 위해 useOptimistic을 추가할 수 있어요.

여기 action이 대기 중일 때 "Submitting..."을 보여주는 버튼이 있어요:

import { useState, startTransition } from 'react';
import Button from './Button';
import { submitForm } from './actions.js';

export default function App() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <Button action={async () => {         
        await submitForm();
        startTransition(() => {
          setCount(c => c + 1);
        });
      }}>Increment</Button>
      {count > 0 && <p>Submitted {count}!</p>}
    </div>
  );
}
import { useOptimistic, startTransition } from 'react';

export default function Button({ action, children }) {
  const [isPending, setIsPending] = useOptimistic(false);

  return (
    <button
      disabled={isPending}
      onClick={() => {
        startTransition(async () => {
          setIsPending(true);
          await action();
        });
      }}
    >
      {isPending ? 'Submitting...' : children}
    </button>
  );
}
export async function submitForm() {
  await new Promise((res) => setTimeout(res, 1000));
}

버튼을 클릭하면, setIsPending(true)가 낙관적 상태를 사용해서 즉시 "Submitting..."을 보여주고 버튼을 비활성화해요. Action이 완료되면, isPending은 자동으로 false로 렌더링돼요.

이 패턴은 Button과 함께 action prop이 어떻게 사용되든 자동으로 대기 중 상태를 보여줘요:

// 상태 업데이트에 대한 대기 중 상태 표시
<Button action={() => { setState(c => c + 1) }} />

// 네비게이션에 대한 대기 중 상태 표시
<Button action={() => { navigate('/done') }} />

// POST에 대한 대기 중 상태 표시
<Button action={async () => { await fetch(/* ... */) }} />

// 모든 조합에 대한 대기 중 상태 표시
<Button action={async () => {
  setState(c => c + 1);
  await fetch(/* ... */);
  navigate('/done');
}} />

action prop의 모든 작업이 완료될 때까지 대기 중 상태가 표시될 거예요.

대기 중 상태를 얻기 위해 useTransition을 사용할 수도 있어요. isPending을 통해서요.

차이점은 useTransitionstartTransition 함수를 제공하는 반면, useOptimistic은 모든 Transition과 함께 작동한다는 거예요. 컴포넌트의 필요에 맞는 걸 사용하세요.


props나 state를 낙관적으로 업데이트하기 {/updating-props-or-state-optimistically/}

Action이 진행되는 동안 즉시 업데이트하기 위해 props나 state를 useOptimistic으로 감쌀 수 있어요.

이 예제에서 LikeButtonisLiked를 prop으로 받고, 클릭하면 즉시 토글해요:

import { useState, useOptimistic, startTransition } from 'react';
import { toggleLike } from './actions.js';

export default function App() {
  const [isLiked, setIsLiked] = useState(false);
  const [optimisticIsLiked, setOptimisticIsLiked] = useOptimistic(isLiked);

  function handleClick() {
    startTransition(async () => {
      const newValue = !optimisticIsLiked
      console.log('⏳ setting optimistic state: ' + newValue);
      
      setOptimisticIsLiked(newValue);
      const updatedValue = await toggleLike(newValue);
      
      startTransition(() => {
        console.log('⏳ setting real state: ' + updatedValue );
        setIsLiked(updatedValue);
      });
    });
  }

  if (optimisticIsLiked !== isLiked) {
    console.log('✅ rendering optimistic state: ' + optimisticIsLiked);  
  } else {
    console.log('✅ rendering real value: ' + optimisticIsLiked);
  }
  

  return (
    <button onClick={handleClick}>
      {optimisticIsLiked ? '❤️ Unlike' : '🤍 Like'}
    </button>
  );
}
export async function toggleLike(value) {
  return await new Promise((res) => setTimeout(() => res(value), 1000));
  // 실제 앱에서는 서버를 업데이트할 거예요
}
import React from 'react';
import {createRoot} from 'react-dom/client';
import './styles.css';

import App from './App';

const root = createRoot(document.getElementById('root'));
// StrictMode를 사용하지 않아서 이중 렌더 로그가 표시되지 않아요.
root.render(<App />);

버튼을 클릭하면, setOptimisticIsLiked가 즉시 표시되는 상태를 업데이트해서 하트를 좋아요로 보여줘요. 한편, await toggleLike가 백그라운드에서 실행돼요. await이 완료되면, setIsLiked가 부모의 "실제" isLiked 상태를 업데이트하고, 낙관적 상태가 이 새 값과 일치하도록 렌더링돼요.

이 예제는 optimisticIsLiked를 읽어서 다음 값을 계산해요. 이건 기본 상태가 변경되지 않을 때 작동하지만, Action이 대기 중인 동안 기본 상태가 변경될 수 있다면 state 업데이터나 리듀서를 사용하는 게 좋아요.

예제는 현재 state를 기반으로 state 업데이트하기를 참고하세요.


여러 값을 함께 업데이트하기 {/updating-multiple-values-together/}

낙관적 업데이트가 여러 관련 값에 영향을 미칠 때, 리듀서를 사용해서 함께 업데이트하세요. 이렇게 하면 UI가 일관성 있게 유지돼요.

여기 팔로우 상태와 팔로워 수를 모두 업데이트하는 팔로우 버튼이 있어요:

import { useState, startTransition } from 'react';
import { followUser, unfollowUser } from './actions.js';
import FollowButton from './FollowButton';

export default function App() {
  const [user, setUser] = useState({
    name: 'React',
    isFollowing: false,
    followerCount: 10500
  });

  async function followAction(shouldFollow) {
    if (shouldFollow) {
      await followUser(user.name);
    } else {
      await unfollowUser(user.name);
    }
    startTransition(() => {
      setUser(current => ({
        ...current,
        isFollowing: shouldFollow,
        followerCount: current.followerCount + (shouldFollow ? 1 : -1)
      }));
    });
  }

  return <FollowButton user={user} followAction={followAction} />;
}
import { useOptimistic, startTransition } from 'react';

export default function FollowButton({ user, followAction }) {
  const [optimisticState, updateOptimistic] = useOptimistic(
    { isFollowing: user.isFollowing, followerCount: user.followerCount },
    (current, isFollowing) => ({
      isFollowing,
      followerCount: current.followerCount + (isFollowing ? 1 : -1)
    })
  );

  function handleClick() {
    const newFollowState = !optimisticState.isFollowing;
    startTransition(async () => {
      updateOptimistic(newFollowState);
      await followAction(newFollowState);
    });
  }

  return (
    <div>
      <p><strong>{user.name}</strong></p>
      <p>{optimisticState.followerCount} followers</p>
      <button onClick={handleClick}>
        {optimisticState.isFollowing ? 'Unfollow' : 'Follow'}
      </button>
    </div>
  );
}
export async function followUser(name) {
  await new Promise((res) => setTimeout(res, 1000));
}

export async function unfollowUser(name) {
  await new Promise((res) => setTimeout(res, 1000));
}

리듀서는 새로운 isFollowing 값을 받아서 새로운 팔로우 상태와 업데이트된 팔로워 수를 단일 업데이트로 계산해요. 이렇게 하면 버튼 텍스트와 수가 항상 동기화 상태를 유지해요.

업데이터와 리듀서 중 선택하기 {/choosing-between-updaters-and-reducers/}

useOptimistic은 현재 상태를 기반으로 상태를 계산하는 두 가지 패턴을 지원해요:

업데이터 함수useState 업데이터처럼 작동해요. setter에 함수를 전달하세요:

const [optimistic, setOptimistic] = useOptimistic(value);
setOptimistic(current => !current);

리듀서는 업데이트 로직을 setter 호출에서 분리해요:

const [optimistic, dispatch] = useOptimistic(value, (current, action) => {
  // current와 action을 기반으로 다음 상태 계산
});
dispatch(action);

업데이터 사용: setter 호출이 자연스럽게 업데이트를 설명하는 계산에 사용하세요. 이건 useState에서 setState(prev => ...)를 사용하는 것과 비슷해요.

리듀서 사용: 업데이트에 데이터를 전달해야 하거나(어떤 항목을 추가할지 등) 단일 Hook으로 여러 유형의 업데이트를 처리할 때 사용하세요.

왜 리듀서를 사용할까요?

리듀서는 Transition이 대기 중인 동안 기본 상태가 변경될 수 있을 때 필수적이에요. 추가가 대기 중인 동안 todos가 변경되면(예: 다른 사용자가 todo를 추가했을 때), React는 새로운 todos로 리듀서를 다시 실행해서 무엇을 보여줄지 재계산해요. 이렇게 하면 새 todo가 오래된 복사본이 아니라 최신 목록에 추가되도록 보장해요.

setOptimistic(prev => [...prev, newItem])와 같은 업데이터 함수는 Transition이 시작된 시점의 상태만 보게 되고, 비동기 작업 중에 발생한 업데이트를 놓치게 돼요.


목록에 낙관적으로 추가하기 {/optimistically-adding-to-a-list/}

목록에 항목을 낙관적으로 추가해야 할 때는 reducer를 사용하세요:

import { useState, startTransition } from 'react';
import { addTodo } from './actions.js';
import TodoList from './TodoList';

export default function App() {
  const [todos, setTodos] = useState([
    { id: 1, text: 'Learn React' }
  ]);

  async function addTodoAction(newTodo) {
    const savedTodo = await addTodo(newTodo);
    startTransition(() => {
      setTodos(todos => [...todos, savedTodo]);
    });
  }

  return <TodoList todos={todos} addTodoAction={addTodoAction} />;
}
import { useOptimistic, startTransition } from 'react';

export default function TodoList({ todos, addTodoAction }) {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (currentTodos, newTodo) => [
      ...currentTodos,
      { id: newTodo.id, text: newTodo.text, pending: true }
    ]
  );

  function handleAddTodo(text) {
    const newTodo = { id: crypto.randomUUID(), text: text };
    startTransition(async () => {
      addOptimisticTodo(newTodo);
      await addTodoAction(newTodo);
    });
  }

  return (
    <div>
      <button onClick={() => handleAddTodo('New todo')}>Add Todo</button>
      <ul>
        {optimisticTodos.map(todo => (
          <li key={todo.id}>
            {todo.text} {todo.pending && "(Adding...)"}
          </li>
        ))}
      </ul>
    </div>
  );
}
export async function addTodo(todo) {
  await new Promise((res) => setTimeout(res, 1000));
  // 실제 앱에서는 서버에 저장할 거예요
  return { ...todo, pending: false };
}

reducer는 현재 todos 목록과 추가할 새 todo를 받아요. 이건 중요한데, 추가가 대기 중인 동안 todos prop이 변경되면(예: 다른 사용자가 todo를 추가했을 때), React가 업데이트된 목록으로 리듀서를 다시 실행해서 낙관적 상태를 업데이트하기 때문이에요. 이렇게 하면 새 todo가 오래된 복사본이 아니라 최신 목록에 추가되도록 보장해요.

각 낙관적 항목은 pending: true 플래그를 포함하고 있어서 개별 항목에 대한 로딩 상태를 보여줄 수 있어요. 서버가 응답하고 부모가 정식 todos 목록을 저장된 항목으로 업데이트하면, 낙관적 상태가 pending 플래그 없이 확정된 항목으로 업데이트돼요.


여러 action 타입 처리하기 {/handling-multiple-action-types/}

여러 유형의 낙관적 업데이트를 처리해야 할 때(항목 추가 및 제거 등), action 객체와 함께 리듀서 패턴을 사용하세요.

이 장바구니 예제는 단일 리듀서로 추가와 제거를 처리하는 방법을 보여줘요:

import { useState, startTransition } from 'react';
import { addToCart, removeFromCart, updateQuantity } from './actions.js';
import ShoppingCart from './ShoppingCart';

export default function App() {
  const [cart, setCart] = useState([]);

  const cartActions = {
    async add(item) {
      await addToCart(item);
      startTransition(() => {
        setCart(current => {
          const exists = current.find(i => i.id === item.id);
          if (exists) {
            return current.map(i =>
              i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
            );
          }
          return [...current, { ...item, quantity: 1 }];
        });
      });
    },
    async remove(id) {
      await removeFromCart(id);
      startTransition(() => {
        setCart(current => current.filter(item => item.id !== id));
      });
    },
    async updateQuantity(id, quantity) {
      await updateQuantity(id, quantity);
      startTransition(() => {
        setCart(current =>
          current.map(item =>
            item.id === id ? { ...item, quantity } : item
          )
        );
      });
    }
  };

  return <ShoppingCart cart={cart} cartActions={cartActions} />;
}
import { useOptimistic, startTransition } from 'react';

export default function ShoppingCart({ cart, cartActions }) {
  const [optimisticCart, dispatch] = useOptimistic(
    cart,
    (currentCart, action) => {
      switch (action.type) {
        case 'add':
          const exists = currentCart.find(item => item.id === action.item.id);
          if (exists) {
            return currentCart.map(item =>
              item.id === action.item.id
                ? { ...item, quantity: item.quantity + 1, pending: true }
                : item
            );
          }
          return [...currentCart, { ...action.item, quantity: 1, pending: true }];
        case 'remove':
          return currentCart.filter(item => item.id !== action.id);
        case 'update_quantity':
          return currentCart.map(item =>
            item.id === action.id
              ? { ...item, quantity: action.quantity, pending: true }
              : item
          );
        default:
          return currentCart;
      }
    }
  );

  function handleAdd(item) {
    startTransition(async () => {
      dispatch({ type: 'add', item });
      await cartActions.add(item);
    });
  }

  function handleRemove(id) {
    startTransition(async () => {
      dispatch({ type: 'remove', id });
      await cartActions.remove(id);
    });
  }

  function handleUpdateQuantity(id, quantity) {
    startTransition(async () => {
      dispatch({ type: 'update_quantity', id, quantity });
      await cartActions.updateQuantity(id, quantity);
    });
  }

  const total = optimisticCart.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );

  return (
    <div>
      <h2>Shopping Cart</h2>
      <div style={{ marginBottom: 16 }}>
        <button onClick={() => handleAdd({
          id: 1, name: 'T-Shirt', price: 25
        })}>
          Add T-Shirt ($25)
        </button>{' '}
        <button onClick={() => handleAdd({
          id: 2, name: 'Mug', price: 15
        })}>
          Add Mug ($15)
        </button>
      </div>
      {optimisticCart.length === 0 ? (
        <p>Your cart is empty</p>
      ) : (
        <ul>
          {optimisticCart.map(item => (
            <li key={item.id}>
              {item.name} - ${item.price} ×
              {item.quantity}
              {' '}= ${item.price * item.quantity}
              <button
                onClick={() => handleRemove(item.id)}
                style={{ marginLeft: 8 }}
              >
                Remove
              </button>
              {item.pending && ' ...'}
            </li>
          ))}
        </ul>
      )}
      <p><strong>Total: ${total}</strong></p>
    </div>
  );
}
export async function addToCart(item) {
  await new Promise((res) => setTimeout(res, 800));
}

export async function removeFromCart(id) {
  await new Promise((res) => setTimeout(res, 800));
}

export async function updateQuantity(id, quantity) {
  await new Promise((res) => setTimeout(res, 800));
}

리듀서는 세 가지 action 타입(add, remove, update_quantity)을 처리하고 각각에 대한 새로운 낙관적 상태를 반환해요. 각 actionpending: true 플래그를 설정해서 Server Function이 실행되는 동안 시각적 피드백을 보여줄 수 있어요.


에러 복구가 있는 낙관적 삭제 {/optimistic-delete-with-error-recovery/}

항목을 낙관적으로 삭제할 때는 Action이 실패하는 경우를 처리해야 해요.

이 예제는 삭제가 실패했을 때 에러 메시지를 표시하는 방법을 보여주고, UI가 자동으로 롤백되어 항목을 다시 보여줘요.

import { useState, startTransition } from 'react';
import { deleteItem } from './actions.js';
import ItemList from './ItemList';

export default function App() {
  const [items, setItems] = useState([
    { id: 1, name: 'Learn React' },
    { id: 2, name: 'Build an app' },
    { id: 3, name: 'Deploy to production' },
  ]);

  async function deleteAction(id) {
    await deleteItem(id);
    startTransition(() => {
      setItems(current => current.filter(item => item.id !== id));
    });
  }

  return <ItemList items={items} deleteAction={deleteAction} />;
}
import { useState, useOptimistic, startTransition } from 'react';

export default function ItemList({ items, deleteAction }) {
  const [error, setError] = useState(null);
  const [optimisticItems, removeItem] = useOptimistic(
    items,
    (currentItems, idToRemove) =>
      currentItems.map(item =>
        item.id === idToRemove
          ? { ...item, deleting: true }
          : item
      )
  );

  function handleDelete(id) {
    setError(null);
    startTransition(async () => {
      removeItem(id);
      try {
        await deleteAction(id);
      } catch (e) {
        setError(e.message);
      }
    });
  }

  return (
    <div>
      <h2>Your Items</h2>
      <ul>
        {optimisticItems.map(item => (
          <li
            key={item.id}
            style={{
              opacity: item.deleting ? 0.5 : 1,
              textDecoration: item.deleting ? 'line-through' : 'none',
              transition: 'opacity 0.2s'
            }}
          >
            {item.name}
            <button
              onClick={() => handleDelete(item.id)}
              disabled={item.deleting}
              style={{ marginLeft: 8 }}
            >
              {item.deleting ? 'Deleting...' : 'Delete'}
            </button>
          </li>
        ))}
      </ul>
      {error && (
        <p style={{ color: 'red', padding: 8, background: '#fee' }}>
          {error}
        </p>
      )}
    </div>
  );
}
export async function deleteItem(id) {
  await new Promise((res) => setTimeout(res, 1000));
  // 항목 3은 에러 복구를 시연하기 위해 항상 실패해요
  if (id === 3) {
    throw new Error('Cannot delete. Permission denied.');
  }
}

'Deploy to production'을 삭제해 보세요. 삭제가 실패하면, 항목이 자동으로 목록에 다시 나타나요.


문제 해결 (Troubleshooting) {/troubleshooting/}

"An optimistic state update occurred outside a Transition or Action" 에러가 나요 {/an-optimistic-state-update-occurred-outside-a-transition-or-action/}

다음과 같은 에러를 볼 수 있어요:

An optimistic state update occurred outside a Transition or Action. To fix, move the update to an Action, or wrap with startTransition.

낙관적 setter 함수는 반드시 startTransition 내에서 호출해야 해요:

// 🚩 잘못됨: Transition 밖에서
function handleClick() {
  setOptimistic(newValue);  // 경고!
  // ...
}

// ✅ 올바름: Transition 내에서
function handleClick() {
  startTransition(async () => {
    setOptimistic(newValue);
    // ...
  });
}

// ✅ 또한 올바름: Action prop 내에서
function submitAction(formData) {
  setOptimistic(newValue);
  // ...
}

Action 밖에서 setter를 호출하면, 낙관적 상태가 잠깐 나타났다가 즉시 원래 값으로 되돌아가요. 이는 Action이 실행되는 동안 낙관적 상태를 "유지"할 Transition이 없기 때문이에요.


"Cannot update optimistic state while rendering" 에러가 나요 {/cannot-update-optimistic-state-while-rendering/}

다음과 같은 에러를 볼 수 있어요:

Cannot update optimistic state while rendering.

이 에러는 컴포넌트의 렌더 단계 중에 낙관적 setter를 호출할 때 발생해요. 이벤트 핸들러, effect, 또는 다른 콜백에서만 호출할 수 있어요:

// 🚩 잘못됨: 렌더 중에 호출
function MyComponent({ items }) {
  const [isPending, setPending] = useOptimistic(false);

  // 이건 렌더 중에 실행돼요 - 허용되지 않아요!
  setPending(true);
  
  // ...
}

// ✅ 올바름: startTransition 내에서 호출
function MyComponent({ items }) {
  const [isPending, setPending] = useOptimistic(false);

  function handleClick() {
    startTransition(() => {
      setPending(true);
      // ...
    });
  }

  // ...
}

// ✅ 또한 올바름: Action에서 호출
function MyComponent({ items }) {
  const [isPending, setPending] = useOptimistic(false);

  function action() {
    setPending(true);
    // ...
  }

  // ...
}

낙관적 업데이트가 오래된 값을 보여줘요 {/my-optimistic-updates-show-stale-values/}

낙관적 상태가 오래된 데이터를 기반으로 하는 것처럼 보인다면, 현재 상태를 기준으로 낙관적 상태를 계산하기 위해 업데이터 함수나 리듀서를 사용하는 걸 고려해 보세요.

// Action 중에 state가 변경되면 오래된 데이터를 보여줄 수 있어요
const [optimistic, setOptimistic] = useOptimistic(count);
setOptimistic(5);  // count가 변경되었어도 항상 5로 설정돼요

// 더 나음: 상대적 업데이트가 state 변경을 올바르게 처리해요
const [optimistic, adjust] = useOptimistic(count, (current, delta) => current + delta);
adjust(1);  // 현재 count가 무엇이든 항상 1을 더해요

자세한 내용은 현재 상태를 기반으로 state 업데이트하기를 참고하세요.


낙관적 업데이트가 대기 중인지 모르겠어요 {/i-dont-know-if-my-optimistic-update-is-pending/}

useOptimistic이 대기 중인지 알려면 세 가지 옵션이 있어요:

  1. optimisticValue === value인지 확인
const [optimistic, setOptimistic] = useOptimistic(value);
const isPending = optimistic !== value;

값이 같지 않으면, 진행 중인 Transition이 있는 거예요.

  1. useTransition 추가
const [isPending, startTransition] = useTransition();
const [optimistic, setOptimistic] = useOptimistic(value);

//...
startTransition(() => {
  setOptimistic(state);
})

useTransition은 내부적으로 useOptimisticisPending에 사용하므로, 이건 옵션 1과 동일해요.

  1. 리듀서에 pending 플래그 추가
const [optimistic, addOptimistic] = useOptimistic(
  items,
  (state, newItem) => [...state, { ...newItem, isPending: true }]
);

각 낙관적 항목이 자체 플래그를 가지고 있으므로, 개별 항목에 대한 로딩 상태를 보여줄 수 있어요.


사이트맵

모든 문서 페이지 개요

profile
프론트에_가까운_풀스택_개발자

0개의 댓글