본문 바로가기

게시물 조회·추가·삭제 컴포넌트

게시물 목록·상세·추가·삭제 컴포넌트에 Axios 요청과 로딩·오류 상태를 연결합니다.

게시물 관련 컴포넌트

조회·추가·삭제 요청은 부모 페이지가 연결합니다. 여기서는 데이터를 받는 props와 동작을 올리는 콜백, 각 컴포넌트의 렌더 분기를 먼저 확인합니다.

조회 데이터와 진행·오류 상태는 부모 페이지에서 PostList와 PostDetail로 내려오고, 삭제·재시도·추가 동작은 부모 콜백으로 올라가는 세 게시물 컴포넌트의 입력과 출력 계약

Props down · callbacks up

게시물 컴포넌트는 상태를 표시하고 동작을 부모에 요청한다

세 컴포넌트의 HTTP 요청과 공유 상태는 부모 페이지가 소유합니다. 목록·상세는 props를 읽고, 삭제·재시도·추가는 콜백으로 요청합니다. 추가 폼의 입력값만 폼 안의 로컬 state입니다.

부모 상태와 컴포넌트 동작의 두 방향PostsPage가 HTTP 요청과 조회·추가·삭제 상태를 소유해 PostList와 AddPostForm에 값을 전달하고 두 자식은 삭제·재시도·추가를 부모 콜백으로 요청합니다. AddPostForm의 입력은 로컬 state이며 부모가 반환한 완료 신호를 읽어 초기화합니다. 별도 상세 route의 SinglePostPage는 PostDetail에 값을 전달하고 재시도를 받습니다. 실선은 props와 상태 전달, 점선은 동작 요청이며 목록→폼→상세의 실행순서를 나타내지 않습니다.PostsPage · HTTP owner조회 · create · deleteSinglePostPage상세 조회 · 404PostListposts · pendingonDelete · onRetryAddPostForm입력은 로컬 stateonAddPostPostDetailpost · 진행 · 오류onRetry실선: 값·상태 전달 · 점선: 동작 요청
부모 상태와 컴포넌트 동작의 두 방향PostsPage가 HTTP 요청과 조회·추가·삭제 상태를 소유해 PostList와 AddPostForm에 값을 전달하고 두 자식은 삭제·재시도·추가를 부모 콜백으로 요청합니다. AddPostForm의 입력은 로컬 state이며 부모가 반환한 완료 신호를 읽어 초기화합니다. 별도 상세 route의 SinglePostPage는 PostDetail에 값을 전달하고 재시도를 받습니다. 실선은 props와 상태 전달, 점선은 동작 요청이며 목록→폼→상세의 실행순서를 나타내지 않습니다.별도 상세 routePostsPageHTTP owner조회 · createdeletePostListposts · pendingonDeleteonRetryAddPostForm입력은 로컬onAddPostSinglePostPage상세 조회 · 404PostDetailpost · 진행 · 오류onRetry실선: 값·상태 전달점선: 동작 요청
PostList

목록·삭제·재시도

↓ 입력: posts, loading, error, deletingId

↑ 동작: onDelete(post.id), onRetry()

기존 항목이 없을 때 loading → error → empty를 먼저 고릅니다. 항목이 있으면 목록을 유지하고 갱신 진행·오류를 함께 표시합니다.

deletingId가 있으면 모든 삭제 버튼을 잠그고, 해당 ID 버튼만 “삭제 중”으로 표시합니다.

PostDetail

상세·재시도·목록 이동

↓ 입력: post, loading, error

↑ 동작: onRetry()

기존 post가 없을 때 loading → error → not found를 고릅니다. 기존 post가 있으면 내용을 유지하고 갱신 진행·오류를 더합니다.

제목·본문·작성자를 표시하며 Link는 /posts로 이동합니다. 서버 404 분류는 부모 페이지가 맡습니다.

AddPostForm

입력·검증·추가 결과

↓ 입력: loading, error

↑ 동작: await onAddPost({ title, body, userId: 1 })

title·body는 로컬 state입니다. trim 검사로 빈 입력을 막고, loading 동안 입력과 제출 버튼을 잠급니다.

콜백의 반환값이 truthy일 때만 두 입력을 비웁니다. POST 성공·실패와 후속 조회는 부모 구현이 판단합니다.

  1. 부모가 조회·mutation 상태를 각각 내려준다

    동일한 loading 하나로 전체 UI를 묶지 않습니다. 조회 loading과 createLoading, 삭제 대상 ID는 서로 다른 의미입니다.

  2. 사용자 동작은 부모 콜백으로 올라간다

    컴포넌트는 Axios를 직접 호출하지 않습니다. 부모가 HTTP 요청, 최신 요청 소유권과 목록 갱신을 연결하고 결과를 다시 props로 전달합니다.

이 그림은 다음 코드의 실제 계약을 요약합니다. onAddPost의 truthy 반환은 부모가 정의한 완료 신호이며, 저장 성공과 목록 갱신 실패를 구분하는 부모 코드는 이어지는 페이지 문서에서 확인합니다.

PostList.jsx

부모가 전달한 게시물 목록을 표시하고 삭제 콜백을 호출하는 버튼을 포함합니다.

src/components/PostList.jsx
import { Link } from 'react-router-dom';
import LoadingSpinner from './LoadingSpinner';
import ErrorDisplay from './ErrorDisplay';

function PostList({ posts, loading, error, onDelete, onRetry, deletingId }) {
  const hasPosts = Array.isArray(posts) && posts.length > 0;

  if (loading && !hasPosts) {
    return <LoadingSpinner />;
  }

  if (error && !hasPosts) {
    return <ErrorDisplay error={error} onRetry={onRetry} />;
  }

  if (!posts || posts.length === 0) {
    return <div style={{ textAlign: 'center', padding: '20px', color: '#666' }}>게시물이 없습니다.</div>;
  }

  return (
    <div style={{ marginTop: '30px' }}>
      <h2 style={{ marginBottom: '20px', color: 'var(--header-color)' }}>전체 게시물 ({posts.length}개)</h2>
      {loading && <p role="status">기존 목록을 유지한 채 새로고침하는 중입니다.</p>}
      {error && <ErrorDisplay error={error} onRetry={onRetry} />}
      <ul style={{ listStyle: 'none', padding: 0 }}>
        {posts.map(post => (
          <li
            key={post.id}
            style={{
              padding: '15px',
              marginBottom: '10px',
              border: '1px solid var(--card-border)',
              borderRadius: '5px',
              backgroundColor: 'var(--card-bg)',
              boxShadow: '0 1px 3px rgba(0,0,0,0.02)',
              display: 'flex',
              justifyContent: 'space-between',
              alignItems: 'center',
            }}
          >
            <Link to={`/posts/${post.id}`} style={{ textDecoration: 'none', color: 'var(--text-color-main)', flexGrow: 1 }}>
              <h3 style={{ margin: '0 0 5px 0', color: '#3498db', fontSize: '1.2em' }}>{post.title}</h3>
              <p style={{ margin: 0, fontSize: '0.9em', color: '#777' }}>작성자 ID: {post.userId}</p>
            </Link>
            <button
              onClick={() => onDelete(post.id)}
              className="button danger"
              disabled={deletingId !== null}
              style={{ padding: '8px 15px', fontSize: '0.8em' }}
            >
              {deletingId === post.id ? '삭제 중...' : '삭제'}
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default PostList;

목록 분기를 손으로 따라가기

posts=[], loading=true, error=null이면 초기 spinner가 나옵니다. posts=[{ id: '1', title: 'A' }]를 이미 갖고 loading=true라면 목록을 지우지 않고 갱신 중 안내를 더합니다. 같은 한 항목에서 error가 생겨도 목록과 오류·retry를 함께 표시합니다. 반대로 빈 목록에서 오류가 생기면 error 분기가 empty보다 먼저 선택됩니다.

deletingId !== null은 모든 삭제 버튼을 잠그는 조건이고, deletingId === post.id는 그중 어떤 버튼에 ‘삭제 중...’을 표시할지 고르는 조건입니다. ID는 문자열로 통일된 seed를 사용합니다.

PostDetail.jsx

부모가 전달한 특정 게시물의 상세 내용을 표시합니다.

src/components/PostDetail.jsx
import LoadingSpinner from './LoadingSpinner';
import ErrorDisplay from './ErrorDisplay';
import { Link } from 'react-router-dom';

function PostDetail({ post, loading, error, onRetry }) {
  if (loading && !post) {
    return <LoadingSpinner />;
  }

  if (error && !post) {
    return <ErrorDisplay error={error} onRetry={onRetry} />;
  }

  if (!post) {
    return <div style={{ textAlign: 'center', padding: '20px', color: '#666' }}>게시물을 찾을 수 없습니다.</div>;
  }

  return (
    <div style={{ maxWidth: '700px', margin: '20px auto', padding: '30px', border: '1px solid var(--card-border)', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', backgroundColor: 'var(--card-bg)' }}>
      {loading && <p role="status">기존 상세를 유지한 채 새로고침하는 중입니다.</p>}
      {error && <ErrorDisplay error={error} onRetry={onRetry} />}
      <h2 style={{ color: '#3498db', marginBottom: '15px' }}>{post.title}</h2>
      <p style={{ fontSize: '1.1em', lineHeight: '1.8' }}>{post.body}</p>
      <p style={{ fontSize: '0.9em', color: '#888', marginTop: '20px' }}>작성자 ID: {post.userId}</p>
      <Link to="/posts" className="button" style={{ marginTop: '20px' }}>
        목록으로 돌아가기
      </Link>
    </div>
  );
}

export default PostDetail;

AddPostForm.jsx

새 게시물을 추가하는 폼입니다.

src/components/AddPostForm.jsx
import { useState } from 'react';
import LoadingSpinner from './LoadingSpinner';
import ErrorDisplay from './ErrorDisplay';

function AddPostForm({ onAddPost, loading, error }) {
  const [title, setTitle] = useState('');
  const [body, setBody] = useState('');

  const handleSubmit = async (e) => {
    e.preventDefault();
    if (!title.trim() || !body.trim()) {
      alert('제목과 내용을 입력해주세요.');
      return;
    }
    const created = await onAddPost({ title, body, userId: 1 });
    if (created) {
      setTitle('');
      setBody('');
    }
  };

  return (
    <div style={{ padding: '25px', border: '1px solid var(--card-border)', borderRadius: '8px', backgroundColor: 'var(--card-bg)', boxShadow: '0 2px 5px rgba(0,0,0,0.03)', marginTop: '40px' }}>
      <h2 style={{ marginBottom: '20px', color: 'var(--header-color)' }}>새 게시물 추가</h2>
      <form onSubmit={handleSubmit}>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="title" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold', color: 'var(--text-color-main)' }}>제목:</label>
          <input
            type="text"
            id="title"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            disabled={loading}
            style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
          />
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="body" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold', color: 'var(--text-color-main)' }}>내용:</label>
          <textarea
            id="body"
            value={body}
            onChange={(e) => setBody(e.target.value)}
            disabled={loading}
            rows="5"
            style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box', resize: 'vertical' }}
          ></textarea>
        </div>
        {loading && <LoadingSpinner />}
        {error && <ErrorDisplay error={error} />}
        <button type="submit" className="button success" disabled={loading}>
          {loading ? '추가 중...' : '게시물 추가'}
        </button>
      </form>
    </div>
  );
}

export default AddPostForm;

혼자 풀기: 폼이 비워지는 조건

title은 ' 제목 ', body는 '내용'이고 loading=false입니다. 부모 onAddPost가 false를 반환할 때와 true를 반환할 때의 제출값과 두 입력 상태를 비교하세요. title이 공백만 있을 때는 부모가 호출될까요?

검증·전송·초기화는 서로 다른 단계

원 폼은 trim으로 빈 입력인지 검사하지만 전송하는 값 자체를 trim하지 않습니다. 따라서 전송 title은 앞뒤 공백이 있는 원 문자열이고 userId는 1입니다. false이면 title과 body를 남겨 수정·재시도할 수 있고, true일 때만 둘을 빈 문자열로 바꿉니다. 공백만 있는 title은 검사를 통과하지 못하므로 부모를 호출하지 않습니다. 값의 공백도 제거하려면 전송 계약을 별도로 바꿔야 하며, 원 코드가 이미 trim된 값을 보낸다고 가정하지 않습니다.