본문 바로가기

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

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

앞 글에서는 프로젝트와 목 API, Axios 인스턴스, useAxios 훅, 공통 UI를 준비했습니다. 이 글에서는 게시물 목록과 상세, 추가 폼의 세 컴포넌트를 만듭니다. 조회와 추가, 삭제 요청은 다음 글의 부모 페이지가 연결하므로, 여기서는 데이터를 받는 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와 PostDetail로 내려오고, 삭제와 재시도, 추가 동작은 부모의 콜백으로 올라가는 구조를 보여 줍니다. 세 컴포넌트의 HTTP 요청과 공유 상태는 부모 페이지가 가지며, 목록과 상세는 props를 읽기만 하고, 추가 폼의 입력값만 폼 안의 로컬 state입니다. PostsPage가 조회와 추가, 삭제 상태를 가지고 PostList와 AddPostForm에 값을 넘기며, 별도의 상세 라우트인 SinglePostPage는 PostDetail에 값을 넘기고 재시도 요청을 받습니다. 그림의 실선은 값과 상태의 전달, 점선은 동작 요청이며, 목록에서 폼, 상세로 이어지는 실행 순서를 뜻하지는 않습니다.

그림 아래의 세 카드는 컴포넌트마다 입력과 동작을 정리합니다. PostList는 posts, loading, error, deletingId를 받아 onDelete(post.id)와 onRetry()를 부르고, 기존 항목이 없을 때는 loading, error, empty의 순서로 화면을 고르며, 항목이 있으면 목록을 유지한 채 갱신 진행과 오류를 함께 보여 줍니다. PostDetail은 post, loading, error를 받아 onRetry()를 부르며, 기존 게시물이 없을 때 loading, error, 찾을 수 없음의 순서로 고르고, 서버의 404를 분류하는 일은 부모 페이지가 맡습니다. AddPostForm은 loading과 error를 받아 await onAddPost({ title, body, userId: 1 })을 부르며, 콜백이 참 같은(truthy) 값을 돌려줄 때만 두 입력을 비웁니다.

맨 아래의 두 칸이 덧붙이듯, 부모는 조회와 변경(mutation)의 상태를 따로 내려 주므로 loading 하나로 전체 UI를 묶지 않으며, 조회의 loading과 createLoading, 삭제 대상의 ID는 서로 다른 뜻입니다. 컴포넌트는 Axios를 직접 부르지 않고, 부모가 HTTP 요청과 최신 요청의 판정, 목록의 갱신을 연결해 결과를 다시 props로 넘깁니다. 저장의 성공과 목록 갱신의 실패를 구분하는 부모 코드는 다음 글에서 확인합니다.


PostList.jsx

부모가 넘긴 게시물 목록을 보여 주고, 삭제 콜백을 부르는 버튼을 포함합니다. 이 컴포넌트는 Axios를 부르지 않고 props로 받은 값만으로 화면을 고릅니다. 아래 코드의 앞부분은 보여 줄 항목이 없을 때의 로딩, 오류, 빈 목록을 차례로 처리하고, 뒷부분은 목록을 그리면서 항목마다 상세 링크와 삭제 버튼을 붙입니다.

src/components/PostList.jsx
import { Link } from 'react-router';
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이면 처음 로딩이므로 스피너가 나옵니다. 이미 posts=[{ id: '1', title: 'A' }]를 가진 채 loading=true라면 목록을 지우지 않고 “기존 목록을 유지한 채 새로고침하는 중입니다.” 안내를 더합니다. 같은 한 항목에서 error가 생겨도 목록과 오류, 다시 시도 버튼을 함께 보여 줍니다. 반대로 빈 목록에서 오류가 생기면 error 분기가 빈 목록 안내보다 먼저 선택됩니다.

deletingId !== null은 모든 삭제 버튼을 잠그는 조건이고, deletingId === post.id는 그 가운데 어느 버튼에 “삭제 중...”을 보여 줄지 고르는 조건입니다. 엄격한 비교를 쓰므로 ID는 앞 글의 seed처럼 문자열로 통일합니다.


PostDetail.jsx

부모가 넘긴 특정 게시물의 상세 내용을 보여 줍니다. 목록과 마찬가지로 요청은 부모가 맡고, 이 컴포넌트는 post, loading, error 세 값의 조합으로 어느 화면을 그릴지만 정합니다.

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

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;

분기의 순서는 PostList와 같습니다. 기존 post가 없을 때는 로딩, 오류, 찾을 수 없음의 순서로 고르고, post가 있으면 내용을 유지한 채 갱신 중 안내와 오류를 위에 더합니다. 아래의 Link는 /posts로 돌아가는 링크이며, className="button"으로 앞 글의 버튼 스타일을 씁니다.


AddPostForm.jsx

새 게시물을 추가하는 폼입니다. 세 컴포넌트 가운데 이 폼만 자기 state를 가지며, 그 state는 입력 중인 제목과 내용뿐입니다. 제출하면 앞뒤 공백을 뺀 값이 비어 있는지 확인한 뒤 부모의 onAddPost를 기다리고, 콜백이 성공을 알려 줄 때만 입력을 비웁니다.

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;

제목과 내용은 폼 안의 로컬 state이고, trim()으로 빈 입력을 막은 뒤 부모의 onAddPost를 기다립니다. loading인 동안에는 두 입력과 제출 버튼을 잠가 같은 게시물이 두 번 전송되지 않게 하고, 부모가 넘긴 error는 다시 시도 버튼 없이 메시지만 보여 줍니다. 부모가 참 같은 값을 돌려줄 때만 입력을 비우므로, 저장에 실패하면 사용자가 쓴 내용이 그대로 남아 고쳐서 다시 보낼 수 있습니다.


정리

  • 세 컴포넌트는 Axios를 직접 부르지 않고, props로 상태를 받아 콜백으로 동작을 부모에 요청합니다.
  • 목록과 상세는 기존 데이터가 없을 때 로딩, 오류, 빈 화면의 순서로 고르고, 데이터가 있으면 유지한 채 갱신과 오류를 더합니다.
  • deletingId는 모든 삭제 버튼을 잠그는 조건과 “삭제 중...”을 보여 줄 버튼을 고르는 조건에 함께 쓰입니다.
  • 추가 폼은 입력만 로컬 state로 가지며, 부모의 반환값이 참 같은 값일 때만 입력을 비웁니다.

연습 문제

1. 폼이 비워지는 조건

title이 ' 제목 ', body가 '내용'이고 loading=false입니다. 부모의 onAddPost가 false를 돌려줄 때와 true를 돌려줄 때, 전송되는 값과 두 입력의 상태를 비교해 보세요. title이 공백뿐이라면 부모가 호출될까요?

해설 보기

폼은 trim()으로 빈 입력인지만 검사하고 전송하는 값 자체는 다듬지 않습니다. 그래서 전송되는 title은 앞뒤 공백이 있는 원래 문자열이고 userId는 1입니다. false라면 title과 body를 그대로 남겨 고쳐서 다시 보낼 수 있고, true일 때만 둘을 빈 문자열로 바꿉니다.

공백뿐인 title은 검사를 통과하지 못하므로 경고창만 뜨고 부모를 호출하지 않습니다. 값의 공백까지 없애려면 { title: title.trim(), body: body.trim(), userId: 1 }처럼 전송하는 값을 따로 바꿔야 합니다.

2. 삭제 중인 목록

posts에 ID가 '1'과 '2'인 게시물이 있고 deletingId가 '2'입니다. 두 삭제 버튼은 각각 어떻게 보일까요? 부모가 실수로 deletingId를 숫자 2로 넘기면 어떻게 달라질까요?

해설 보기

deletingId !== null이므로 두 버튼이 모두 잠기고, deletingId === post.id가 참인 2번 버튼만 “삭제 중...”, 1번 버튼은 “삭제”로 보입니다.

숫자 2라면 두 버튼은 여전히 잠기지만, 문자열 '2'와 숫자 2는 엄격한 비교에서 다르므로 어느 버튼에도 “삭제 중...”이 보이지 않습니다. 그래서 ID의 타입을 seed와 같은 문자열로 통일합니다.

다음 글 데이터 연동 페이지와 검증에서는 이 컴포넌트들을 페이지에 연결해 조회와 추가, 삭제 요청을 보내고, 라우트를 구성한 뒤 지연과 실패 상황을 점검합니다.