리액트 연습 프로젝트 (2)

김세현·2022년 6월 19일
0

React

목록 보기
5/10
post-thumbnail

연습 프로젝트를 통해 만들고자 하는 UI의 최종 상태는 다음과 같다.

사용자 이름과 나이를 입력하면 아래 사용자 리스트에 추가되고, 목록에서 컨텐츠를 클릭하면

해당 컨텐츠가 삭제되는 기능을 가진 화면이다.

또한 잘못된 값을 입력했을 때 모달 창을 통해 오류를 보여준다.


재사용 가능한 "카드" 컴포넌트 만들기 (컴포넌트 합성)


입력폼들을 감싸고 있는 흰색 바탕을 가진 카드 형태의 디자인은 바로 아래에서 User 리스트를 보여주는 데에도 재사용되고 있다. (나중에 모달 컴포넌트에서도 사용된다)

그래서 재사용 가능한 "카드" 컴포넌트를 만들고자 한다.

폴더 구조는 다음과 같다.
(스타일은 css 모듈을 이용하였다.)

일단, AddUser컴포넌트를 만들고 이름과 나이를 받기 위한 입력폼과 버튼을 만든다.

// AddUser.js
     <form onSubmit={submitHandler}>
        <label htmlFor="username">Username</label>
        <input id="username" type="text"></input>
        <label htmlFor="age">Age (Years)</label>
        <input id="age" type="number"></input>
        <button type="submit">Add User</button>
      </form>

그리고 이를 감싸주기 위한 Card 컴포넌트를 만든다.

  1. Card 컴포넌트는 공통적인 스타일(재사용 될 스타일)이 적용된 <div>를 반환한다.

  2. Card 컴포넌트는 감싸고 있는 컨텐츠들을 출력하기 위해 props.children을 이용한다.

// Card.js
return (
	<div className={...}>{props.children}</div>
);

// AddUser.js
return (
    <Card>
      <from>
        ...
      </form>
    </Card>
);

이때, 주의 할 것은 Card는 내장된 HTML 컴포넌트가 아닌 사용자 지정 컴포넌트라는 것이다.

<div> <form> <input> ... 등은 내장된 HTML 컴포넌트이기 때문에 기본적으로

css 스타일을 적용할 수 있는 클래스 이름(className)을 가질 수 있다.

하지만 사용자 지정 컴포넌트는 className이라는 속성을 따로 지정해줘도 리액트는 이를 가지고 뭘 해야 할 지 모른다.

그래서 props를 통해서 css 스타일을 적용할 수 있는 클래스 이름을 Card 컴포넌트에 전달하고

Card 컴포넌트에서는 이를 기본 HTML 컴포넌트인<div>의 className으로 지정하고 이를 다시 반환하여 사용할 수 있다.


재사용 가능한 "버튼" 컴포넌트 만들기


위에서 재사용 가능한 "카드" 컴포넌트를 만들어 보았고,

추가적으로 재사용 가능한 "버튼" 컴포넌트를 만들어 보았다.

이렇게 재사용 가능한 컴포넌트를 만들어 보게 되면서,

몇 개월 전 팀 프로젝트를 하면서 재사용 가능한 컴포넌트에 대해서 고민했던 것이 해결되었다.

이번 계기로 공통적인 디자인을 가진 컴포넌트를 만들고 이를 쉽게 재사용할 수 있겠다는 생각이 든다.

/* 보라색 바탕을 가진 버튼 컴포넌트
Button.module.css */

.button {
  font: inherit;
  border: 1px solid #4f005f;
  background: #4f005f;
  color: white;
  padding: 0.25rem 1rem;
  cursor: pointer;
}

.button:hover,
.button:active {
  background: #741188;
  border-color: #741188;
}

.button:focus {
  outline: none;
}

사용자 지정 Button 컴포넌트

// Button 컴포넌트를 사용하는 AddUser.js 컴포넌트
  return (
    <Card className={styles.input}>
        ...
        //사용자 지정 Button 컴포넌트의 사용 : 첫 번째 케이스
        <Button type="submit">추가</Button> 

      	//사용자 지정 Button 컴포넌트의 사용 : 두 번째 케이스
        <Button type="button" onClick={deleteHandler}>삭제</Button>
         ...
    </Card>
  );


사용자 입력 상태로 관리하기 및 유효성 검사


사용자 입력값 검증 조건
1. 공백 입력 금지
2. 나이는 공백 금지 및 1이상의 값을 입력해야 한다.

입력된 사용자 이름, 나이가 유효한지 아닌지를 나타내는 상태 변수

  const [isNameValid, setIsNameValid] = useState(true);
  const [isAgeValid, setIsAgeValid] = useState(true);

추가 버튼을 눌렀을 때, 유효한 값인지를 검사하는 로직

  function submitHandler(e) {
    e.preventDefault();
    if (enteredUserName.trim().length === 0) {
      setIsNameValid(false);
      return;
    }
    if (enteredUserAge.trim().length === 0 || +enteredUserAge < 1) {
      setIsAgeValid(false);
      return;
    }
    setEnteredUserAge("");
    setEnteredUserName("");
  }

입력 값이 유효한지 아닌지에 따라서 input 태그의 클래스 이름을 동적으로 설정하여 css

스타일을 다르게 적용한다.


사용자 추가 및 사용자 리스트 출력


현재 사용자를 추가하는 컴포넌트만 존재하고 있고, 컴포넌트 트리의 구조는 아래과 같다.

그리고 추가된 사용자 목록을 화면에 보여주는 기능이 필요한데, 두 가지 방법을 생각해 볼 수 있다.

  1. 기존 AddUser 컴포넌트에서 사용자 목록을 관리하고 동시에 사용자 목록을 화면에 보여줄 것인가?

  2. 사용자 목록을 보여주기 위한 UsersList 컴포넌트를 따로 만들어서 컴포넌트를 분리할 것인가?

고유한 기능과 책임을 담당하도록 컴포넌트를 분리하여 구성하기

각자 고유한 책임과 기능을 갖도록 입력받는 컴포넌트와 출력하는 컴포넌트를 분리하여 아래와 같은 구조로 만들었다.

또한 생각해 볼만한 것은 사용자 목록 데이터(상태)를 어디서 관리할 것인가?이다.

상태를 관리해야 하는 위치를 정하기 위해서 고려해 볼만한 점은

  1. 추가 버튼이 눌렸을 때 이벤트를 수신해서 AddUser 컴포넌트에서 사용자 목록 데이터에 접근하여 추가할 수 있어야 한다.

  2. UserList 컴포넌트에서 사용자 목록 데이터에 접근하여 사용할 수 있어야 한다.

이 두 가지 조건을 충분히 만족하는 위치는 두 컴포넌트에 접근할 수 있는 가장 가까운 부모 컴포넌트이다.

App 컴포넌트에서 사용자 목록 데이터를 상태로 관리하고 상태 끌어올리기를 통해 상

태를 변경하거나 변경된 상태를 props로 전달한다면 위의 두 가지 조건을 충분히 만족할 수 있다.


따라서 App 컴포넌트에서는 아래와 같이 사용자 목록 상태를 가지고 있고, AddUser , UsersList

컴포넌트에 필요한 상태 변경 함수 및 상태를 props로 전달하고 있다.

참고 : 이전 상태에 의존하는 상태를 변경할 때는 아래와 같이 상태를 변경해 준다.

  function addUserHandler(user) {
    setUsers((prevUsers) => [user, ...prevUsers]);
  }

모달 구현


사용자가 잘못된 값을 입력했을 때 모달 창을 띄워 주도록 ErrorModal 컴포넌트를 구현했다.

위에서 구현했던 재사용 가능한 컴포넌트 Card, Button을 이용해 ErrorModal 컴포넌트의 UI를 디자인 했다.

  1. 모달 창 자체의 틀을 구현하기 위한 Card 컴포넌트의 재사용
// ErrorModal.js
return (
     <Card className={styles.modal}>
  		...
     </Card>
 )
  1. 닫기 버튼을 위한 Button 컴포넌트의 재사용
// ErrorModal.js
return (
      <Card className={styles.modal}>
  		...
          <Button onClick={props.onCloseModal}>닫기</Button>
        ...
      </Card>
 )

모달 컴포넌트를 사용할 위치도 고려해야 하는데 두 가지를 생각해 볼 수 있다.

  1. 직접 모달 창을 트리거할 수 있는 로직이 담긴 AddUser 컴포넌트 ??
    (사용자 입력 폼에서 유효하지 않은 값을 입력했을 때)

  2. 전체적인 UI위에 겹쳐보여야 하기 때문에 최상 컴포넌트인 App 컴포넌트 ??

나는 직접 모달을 사용하는 (트리거 하는) 위치인 AddUser 컴포넌트에서 사용하기로 했다.

// AddUser.js
return (
    <div>
       <ErrorModal/>
       <Card className={styles.input}>
            ... // 사용자 입력 폼 관련 UI
       </Card>
    </div>
  );

그리고 ErrorModal 컴포넌트 또한 재사용하기 위해 모달에 사용될 컨텐츠를 props로 받아와 동적으로 설정해 준다.

//ErrorModal.js
  return (
    <div>
      <Card className={styles.modal}>
        <header className={styles.header}> //모달 제목
          <h2>{props.title}</h2>
        </header>
        <div className={styles.content}>   //모달 내용
          <p>{props.message}</p>
        </div>
        <footer className={styles.actions}>
          <Button onClick={props.onCloseModal}>닫기</Button>
        </footer>
      </Card>
    </div>
  );

AddUser 컴포넌트에서 모달 컴포넌트를 보여주고, 보여주지 않을 지를 결정하기 위한 상태를 담고있을 변수가 필요하다.

falsy한 값이 상태 변수에 담겨 있다면, 모달이 보여지지 않고, truthy한 값이 상태 변수에

담겨 있다면 모달이 보여지도록 조건부 렌더링을 활용한다.

// AddUser.js
 const [error, setError] = useState(false);

...

//알맞은 나이를 입력하지 않았을 때 모달 내용을 동적으로 설정
setError({
        title: "올바른 나이를 입력하세요",
        message: "나이는 1 이상의 숫자만 입력할 수 있습니다.",
});

//알맞은 이름을 입력하지 않았을 때 모달 내용을 동적으로 설정
setError({
        title: "올바른 이름을 입력하세요",
        message: "공백을 제외하고 한 글자 이상을 입력해야 합니다.",
});

...

return (
  ...
   {error && (
        <ErrorModal
          title={error.title}
          message={error.message}
          onCloseModal={closeModalHandler}
        />
   )}
  ...
)

모달 창이 띄워져 있다면, 모달 창을 닫기 위한 방법이 두 가지 존재한다.

  1. 닫기 버튼을 클릭했을 때

  2. 모달 창 바깥을 클릭했을 때

이를 위해 모달 상태를 falsy 값으로 업데이트 하는 함수인 closeModalHandler를 구현하고 이를

ErrorModal 컴포넌트에게 props로 전달한다.

// AddUser.js
  function closeModalHandler() {
    setError(false);
  }
 
  return (
   {error && (
        <ErrorModal
          title={error.title}
          message={error.message}
          onCloseModal={closeModalHandler}
        />
      )}
  ...
 )
  ...
// ErrorModal.js
return (
  <div>
    //바깥쪽 클릭시
    <div className={styles.backdrop} onClick={props.onCloseModal} />
    <Card className={styles.modal}>
      ...
      //닫기 버튼 클릭시
      <Button onClick={props.onCloseModal}>닫기</Button>
      ...
    </Card>
 </div>
)


추가) 프래그먼트


함수형 컴포넌트에서 JSX 요소들을 반환할 때, 감싸지 않은 인접한 요소들을 한꺼번에 반환하면 에러가 발생한다.

...
return (
   <div>test</div>
   <div>에러 발생</div> //인접한 두 요소를 한 번에 반환
)

컴포넌트에서 return안에 반환하는 jSX요소는 1개여야 한다.

또한 요소를 변수, 상수 또는 속성에 저장하려면 그 값또한 JSX 요소 1개여야 한다.

위의 예처럼 2개 이상의 인접한 요소들을 한꺼번에 반환하거나 변수에 저장한다면 에러가 발생한다.


이때, 반환되는 1개의 상위 요소는 여러 자식 요소들을 가지고 있을 수 있다.

그리고 그 자식들은 서로 인접해있을 수 있다.

리액트에서는 아래처럼 상위 JSX 요소 하나로 감싸여진 형태여야만 반환되거나 변수에 저장할 수 있다.

...
return (
  	<div> //반환되는 1개의 상위 요소 (Wrapper)
   		<div>test</div>
   		<div>에러 발생하지 않음</div>
   	<div>
)

JSX에서의 이러한 규칙은 자바스크립트의 규칙을 따르는 것이다.

자바스크립트 함수에서 동시에 둘 이상의 값을 동시에 반환할 수 없듯이 리액트 함수형

컴포넌트에서도 둘 이상의 요소를 동시에 반환할 수 없는 것이다.

여러 값을 동시에 반환하기 위해서 배열을 생각해볼 수 있는데, 배열도 결국 객체 하나

를 반환하는 것 뿐이지 배열 두 개를 반환하는 것은 아니다.

그럼 어떻게?

반환되는 인접한 요소들을 하나의 요소로 감싸주면 된다.

보통 이럴때에 <div> 요소를 많이 사용하지만,

반드시 <div>일 필요는 없고 <li> 또는 사용자 정의 컴포넌트 등으로 감싸줄 수도 있다.

또한 위에서 말한 자바스크립트에서 배열을 사용할 수도 있다.

 return [
   <div>test</div>,
   <div>배열을 통한 반환</div>, // 콤마를 통해 요소들을 구분해 주어야 한다.
 ];

리액트에서 map 함수를 통해 여러 요소들을 반환할 수 있는 것처럼 리액트는 배열 형태의 JSX 요소를 다룰 수 있다. (map 함수의 결과 = 배열)

다만, 배열 리터럴 형태로 반환하는 방법이 일반적이지 않고 익숙하지 않을 뿐이다.

그런데 이렇게 반환하게 되면, 아래와 같은 에러를 볼 수 있다.

리액트가 JSX 요소들이 담긴 배열을 해석할 때에는 모든 요소에 대한 key가 필요하다.

따라서 배열 리터럴 []을 사용해 반환할 때에도 map함수를 사용할 때처럼 key 속성을 설정해 주어야 한다.

  return [
    <div key="first_element">test</div>, // 요소들의 key 속성을 설정
    <div key="second_element">배열을 통한 반환</div>,
  ];

이렇게 해도 전혀 문제는 없지만, 일반적으로 하나의 JSX 요소를 반환하기 위해 배열을 사용하진 않는다.

배열을 사용해 여러 요소들을 한 꺼번에 반환하는 것보다 <div>, <li> 등 하나의

요소로 감싸주는 것이 훨씬 편하다.

또 다른 문제 : <div> soup

...
return (
  	<div> //반환되는 1개의 상위 요소 (Wrapper)
   		<div>test</div>
   		<div>에러 발생하지 않음</div>
   	<div>
)

하지만, 이렇게 특정한 기능은 없지만 단지 여러 요소들을 감싸주기 위한 컴포넌트, 요소들을

사용하는 것은 실제로 DOM에도 렌더링 되는 것이므로 결과적으로 불필요한 요소들을 렌더링

하게 되어 성능에 영향을 끼칠 수 있다.

//App.js
  return (
    <div> // 감싸주기 위해 사용된 <div>
      <AddUser onAddUser={addUserHandler} />
      <UsersList users={users} onDeleteUser={deleteUserHandler} />
    </div>
  );

      
//AddUser.js
  return (
    <div className={...}> // 감싸주기 위해 사용된 <div>
      <div>요소 1</div>
      <div>요소 2</div>
    </div>
  );
      
  • 실제 렌더링된 DOM 구조

해결 방법 : Wrapper 컴포넌트

단지, 감싸주기 위해 사용되었던 요소의 불필요한 렌더링을 막기 위해 사용자 정의 컴포넌

Wrapper를 만들고 다음과 같이 코드를 작성한다.

// Wrapper.js
function Wrapper(props) {
  return props.children;
}

export default Wrapper;

Wrapper 컴포넌트에서는 JSX가 전혀 사용되지 않고, 단지 props.children을 반환한다.

props.children은 사용자 정의 컴포넌트를 사용할 때, 열고 닫는 태그 사이에 있는 모든 내용을 담고 있다.

<MyComponent>
     ... // 여기 안에 있는 내용들이 MyComponent에서 props.children이 된다.
</MyCOmponent>

이렇게 props.children을 반환하는 Wrapper 컴포넌트는 어떤 JSX 요소도 반환하

지 않으면서, 하나의 요소만 반환해야 하는 문법적 요구사항도 충족하고 있다.

//App.js
  return (
    <Wrapper> // 감싸주기 위해 사용된 <Wrapper>
      <AddUser onAddUser={addUserHandler} />
      <UsersList users={users} onDeleteUser={deleteUserHandler} />
    </Wrapper>
  );

      
//AddUser.js
  return (
    <Wrapper> // 감싸주기 위해 사용된 <Wrapper>
      <div>요소 1</div>
      <div>요소 2</div>
    </Wrapper>
  );
      
  • 실제 페이지에는 감싸주기 위해 사용된 Wrapper 컴포넌트가 렌더링 되지 않았다.

리액트의 Fragment

여러 요소들을 감싸주기 위해 만든 사용자 정의 컴포넌트 Wrapper를 사용해 div soup 문제를 개선할 수 있었다.

사실, 직접 Wrapper 컴포넌트를 만들 필요없이 리액트에서 기본적으로 제공하는 Fragment 컴포넌트를 사용하면 된다.

//App.js
  return (
    <React.Fragment> // 감싸주기 위해 사용된 <React.Fragment>
      <AddUser onAddUser={addUserHandler} />
      <UsersList users={users} onDeleteUser={deleteUserHandler} />
    </React.Fragment>
  );

또는 아래처럼 간단히 사용할 수 있다.

//App.js
  return (
    <>
      <AddUser onAddUser={addUserHandler} />
      <UsersList users={users} onDeleteUser={deleteUserHandler} />
    </>
  );

위의 두 코드는 Wrapper 컴포넌트를 사용할 때처럼 실제 HTML 요소를 DOM에 렌더링 하지 않는다.


추가) useState 대신 useRef를 통해 사용자 입력 관리하기


기존 useStateonChange 이벤트 리스너를 통해 사용자 입력을 관리하게 되면

키를 누르는 이벤트가 발생할 때마다 상태가 변경되고 이 값을 다시 input 요소의

value 속성으로 전달하여 사용자 화면에 보여지는 값을 업데이트 해준다.

이 방법이 틀린 것은 아니지만, 사용자 입력 값이 폼을 제출할 때에만 필요한데도 키를 입

력할 때마다 상태를 업데이트 한다는 것은 비효율적이다.

useRef 훅 사용하기

useRef() 를 사용하여 Ref 객체를 만들고, 이 객체를 우리가 선택하고 싶은 DOM 에 ref 값으로 설정해주어야 한다.

아래와 같은 코드에서 nameInput을 콘솔로 출력해 보면 current 속성을 가지고 있는 객체가 출력된다.

Ref 객체의 .current 값은 우리가 원하는 DOM 을 가르키게 되어 직접 DOM을 조작할 수 있다.

// AddUser.js
import { useRef } from "react";
...

function AddUser(props) {
  const nameInputRef = useRef();

  function submitHandler(e) {
    e.preventDefault();
    console.log(nameInputRef); // nameInput 출력하기
    ...
  }
  
  return (
    <>
      ...
          <input ref={nameInputRef}></input>
      ...
    </>
  );
}
...
  • 출력

nameInputRef.current.value : nameInputRef라는 이름에 담긴 객체의 current 속성에는 input 요소가 담겨 있고,inputvalue 속성에 접근한다.
(모든 input 요소는 value 속성을 가지고 있다.)

주의 : DOM을 직접 조작하기 위해 useRef 훅을 사용하는 경우는 드물다.
DOM 요소를 조작하는 건 리액트가 할 일이지 개발자가 할 일이 아니기 때문이다.
useRef 훅은 꼭 필요할 때에만 사용한다.

여기서 사용된 useRef는 DOM을 실제로 조작해 요소를 추가, 삭제나 css 클래스를 변경하기 위한 목적이 아니라 단지 사용자가 입력한 내용을 바꾸기 위해 DOM을 조작해 input 요소의 value 값을 재설정하는 경우이기 때문에 큰 문제가 되지 않는다.

사용자 입력값을 관리하기 위해 useState 훅을 사용하는 것도 괜찮은 방법이지만,

useRef 훅을 이용해 사용자가 입력한 값을 필요할 때에만 받아왔더니 코드가 간결해 졌다.

useRef 훅을 사용하고 나서 발생한 문제

사용자 입력값이 조건을 만족하지 못했을 때 입력 폼의 색깔이 빨간색으로 바뀌면서 경고(모달)창이 뜨게 되어 있다.

useState 훅을 사용했을 때에는 키 입력 이벤트가 발생할 때마다 검증하여 유효한 값이

입력되면 입력 폼의 색깔이 다시 정상적으로 바뀌지만,

useRef 훅을 사용했을 때에는 입력 값을 전송하는 버튼을 눌렀을 때에만 입력 값을 검증할 수 있다.

따라서 사용자가 처음에 잘못된 값을 입력해서 입력 폼의 색깔이 빨간색으로 바뀐다면,

사용자가 다시 올바른 값을 입력하고 제출 버튼을 누르기 전까지는 입력폼의 색깔이 빨간색인 상태로 유지된다.

useState 훅을 사용할 때보다 사용자 경험이 좋지 않을 수 있다.

profile
under the hood

0개의 댓글