[React] Next 환경에서 최근 검색어 저장 및 구현하기

이준희·2021년 9월 29일
8

REACT 정복기

목록 보기
8/9

✍🏼 선행 지식

  • localStorage
  • JSON 정적 메서드 (JSON.stringify/ JSON.parse)
  • Array.prototype.map()
  • Array.prototype.filter()

📚 참고자료

velog, 리액트로 최근 검색어 기능 구현해보기 🔥

기존에 다른 벨로거 분이 공유해주신 코드를 next와 typescript 환경에 맞춰 적용한 글입니다!

🔥 분석하기

우선 디자인 사항부터 보자

검색창검색 결과로 이동

요구사항 정리

컴포넌트는 크게 2개로 나눌 수 있다.

만약 익숙하지 않다면, 컴포넌트를 나누지 말고 우선 한 컴포넌트에 모두 구현한 다음, 이후에 자식 컴포넌트를 만들어 그때 props로 전달하는 것을 추천한다.

  • src/page/search
  • src/components/TogetherSearchBar

메인 기능은 search 페이지에 구현하였고, 검색어 관련 데이터는 props로 하여금 TogetherSearchBar 컴포넌트에 전달하였다

분석 1

  • 1 번 요구사항을 해결하기 위해서 검색 결과를 <input>태그 내에서 관리하고 해당 e.target.value를 결과 페이지로 다이나믹 라우팅하면서, 로컬 스토리지에 저장한다
  • 버튼 클릭 이벤트를 통해 기존 배열에서 해당 클릭된 타깃 검색어를 지워줘야 하므로, 일반 배열 형식으로 바로 푸쉬하는 것보다는 id 값을 통해 객체형식으로 배열에 넣어주었다

분석 2

  • 2번 요구사항을 해결하기 위해서는 검색 결과를 담고 있는 localStroage에 저장된 JSON 형식의 배열을 파싱하여 다시 js 구조로 변환하고, 이를 바탕으로 렌더링한다

분석 3

  • Array.prototype.map 을 통해 렌더링한 배열에는 id 값이 포함되어 있을 것이다 (사전에 넣어주었기 때문에)
  • 해당 id 값을 바탕으로 state로 관리되고 있는 배열에서 filter 한다

분석 4

  • state로 관리되고 있는 배열을 setState로 하여금 빈배열( [ ] )로 변환한다

📁 pages/search

① state, props

📁/pages/search

interface keyInterface {
  id: number
  text: string
}

const Search = () => {
  // 로컬 스토리지에 저장한 검색어를 관리할 useState keywords
  const [keywords, setKeywords] = useState<keyInterface[]>([])

  // ① window 즉, 브라우저가 모두 렌더링된 상태에서 해당 함수를 실행할 수 있도록 작업
  useEffect(() => {
    if (typeof window !== 'undefined') {
      const result = localStorage.getItem('keywords') || '[]'
      setKeywords(JSON.parse(result))
    }
  }, [])

  // ② keywords 객체를 의존하여, 변경될 경우 새롭게 localStroage의 아이템 'keywords'를 세팅한다
  useEffect(() => {
    localStorage.setItem('keywords', JSON.stringify(keywords))
  }, [keywords])

next 환경에서 프레임워크가 제공하는 SSG를 기본적으로 사용하고 있기 때문에, 우리는 브라우저가 온전히 HTML 파일을 파싱하지 않은 상태에서 서버로부터 넘겨받은 html 파일을 바로 렌더링한다.

keywords를 통해서 검색어를 관리할 예정이지만, useState에 바로 localStorage를 넣을 경우 localStorage가 없는 불상사가 일어난다.

일종의 안전 장치로 useEffect를 통해 온전히 window 객체가 불려진 상태에서 로컬 스토리지에 들어있는 'keywords' 라는 key를 가진 요소를 불러오거나 빈 '[ ]' 문자열을 setKeywords를 통해 parsing 후에 넣어준다

② CRUD

📁/pages/search
  // 검색어 추가
  const handleAddKeyword = (text: string) => {
    const newKeyword = {
      id: Date.now(),
      text: text,
    }
    setKeywords([newKeyword, ...keywords])
  }

  // 단일 검색어 삭제
  const handleRemoveKeyword = (id: number) => {
    const nextKeyword = keywords.filter((keyword) => {
      return keyword.id != id
    })
    setKeywords(nextKeyword)
  }

  //검색어 전체 삭제
  const handleClearKeywords = () => {
    setKeywords([])
  }

검색어를 추가할 때 앞서 말했던 것처럼 newKeyword라는 객체를 만들어 우리가 입력한 text 값과 id: Date.now()를 담아 배열 형식으로 저장한다

setKeywords([newKeyword, ...keywords])

에서 기존 keywords를 전개 연산자(...)를 붙여 목록의 값으로 바꾸고 그 앞에 우리가 만든 newKeyword를 추가하였다

setKeywords(keywords.unshift(newKeyword))

와 같은 결과를 얻을 수 있을 것이다. 최신의 검색어가 더 상단에 위치해야 하므로 앞에 키워드를 삽입한다.

이렇게 추가될 경우

  useEffect(() => {
    localStorage.setItem('keywords', JSON.stringify(keywords))
  }, [keywords])

를 통해 keywords의 변화를 감지하고 새롭게 localStorage에 keywords라는 키에 직렬화를 한 keywords를 넣어준다

직렬화?

JSON 객체는 우리가 아는 구조와는 비슷하면서도 다르다. js와 달리 키에도 " " 를 붙여줘야 한다

{
    "name": "식빵",
    "family": "웰시코기",
    "age": 1,
    "weight": 2.14
}

따라서 JSON 객체의 정적 메서드인 JSON.stringify()를 사용하여 기존 데이터 구조를 JSON 형식으로 변환하여 로컬 스토리지에 저장할 수 있도록 만든다

③ 렌더링

📁/pages/search
return (
    <div css={searchWrap}>
      <TogetherSearchBar onAddKeyword={handleAddKeyword} />

      <div>
        <h2>최근 검색어</h2>
        {keywords.length ? (
          <button type="button" onClick={handleClearKeywords}>
            전체 삭제
          </button>
        ) : (
          <button />
        )}
      </div>

      <ul>
        {keywords.length ? (
          keywords.map((k) => (
            <li key={k.id}>
              <p>{k.text}</p>
              <button className="removeBtn" type="button" onClick={() => handleRemoveKeyword(k.id)}>
                <img src="/images/together/btn_delete.svg" alt="삭제" />
              </button>
            </li>
          ))
        ) : (
          <div>최근 검색어가 없습니다</div>
        )}
      </ul>
    </div>
  )

keywords (로컬 스토리지에 저장된 JSON 형식의 데이터를 파싱한 배열)의 길이를 바탕으로 조건부 렌더링을 사용한다.

배열의 길이인 length를 가지고 (?) 연산자 대신 (&&) 연산자를 사용할 경우, 길이가 0인 falsy한 값일 경우에 화면에 0이 렌더링되므로 (?) 연산자로 렌더링해주자!

📁 components/TogetherSearchBar

📁 components/TogetherSearchBar

type Props = {
  onAddKeyword: (string: string) => void
}

function TogetherSearchBar({ onAddKeyword }: Props) {
  // ① props로 전달받은 onAddKeyword의 데이터로 들어갈 state이다
  const [searchValue, setSearchValue] = useState<string>('')

  const router = useRouter()

  const onChangeSearch = useCallback((e) => {
    setSearchValue(e.target.value)
  }, [])

  const onSubmit = useCallback(
    (e) => {
      e.preventDefault()
      // ② 로컬 스토리지에 해당 searchValue를 저장해야 한다
      // ③ 다이나믹 라우팅을 위해 해당 쿼리를 받을 페이지로 push 해주었다
      router.push(`/together/search_result/${searchValue}`)
      onAddKeyword(searchValue)
      setSearchValue('')
    },
    [searchValue, router, onAddKeyword]
  )

  return (
    <header>
      <form onSubmit={onSubmit}>
        <input type="search" value={searchValue} onChange={onChangeSearch} placeholder="모임 이름 / 소개 / 태그 검색" />
      </form>
    </header>
  )
}

export default TogetherSearchBar

상위 컴포넌트인 pages/search에서 <TogetherSearchBar onAddKeyword={handleAddKeyword} /> 로 하여금 input 값을 추가할 함수를 props로 전달하였다.

props를 전달받은 하위 컴포넌트이기 때문에 해당 props에 타입에 대해 정의해줘야 한다

만약 잘 모르겠다면 일단 any로 다 놓고 정상적으로 동작하는 지 확인한 후에 해당 타입을 분석하여 맞춰가는 것을 추천한다!

결과보기

검색어 추가

검색어 삭제 (단일/전체)

라우팅 적용시

profile
https://junheedot.tistory.com/ 이후 글 작성은 티스토리에서 보실 수 있습니다.

1개의 댓글

comment-user-thumbnail
2023년 5월 10일

이해가 잘 됩니다. 감사합니다.

답글 달기