๐ŸŽฏ Zustand๋ฅผ ํ†ตํ•ด ์ „์—ญ์ƒํƒœ๋ฅผ ๊ด€๋ฆฌํ•˜๊ณ  ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€์™€ ๋„์„œ ๋ชฉ๋ก ํŽ˜์ด์ง€๋ฅผ ์ œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.


๐Ÿ“— Today I Learned

๋กœ๊ทธ์ธ๊ณผ ์ „์—ญ ์ƒํƒœ

๋กœ๊ทธ์ธ ๋กœ์ง

                     [๋กœ๊ทธ์ธ ์„ฑ๊ณต]
                          โ†“
                      [ํ† ํฐ ํš๋“]
                          โ†“
      โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
      โ†“                                         โ†“
  [์ „์—ญ ์ƒํƒœ]                                 [ํ† ํฐ ์ €์žฅ]     
isLoggedIn:true                            localStorage         
  (Zustand)                                     โ†‘
                                        [http client ์„ค์ •]
                                 headers.Authorization์— ํ† ํฐ ํฌํ•จ

๋กœ๊ทธ์ธ์— ์„ฑ๊ณตํ•˜๋ฉด ํ† ํฐ๐Ÿ”‘์ด ๋ฐœํ–‰๋˜๊ณ , ๋‘ ๊ณณ์— ์“ฐ์ด๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

  • ํ•˜๋‚˜๋Š”, ์ „์—ญ ์ƒํƒœ์— ์ €์žฅํ•˜์—ฌ ๋กœ๊ทธ์ธ ์—ฌ๋ถ€๋ฅผ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค.

  • ๋‚˜๋จธ์ง€๋Š”, localStorage์— ์ €์žฅํ•˜์—ฌ ์ƒˆ๋กœ๊ณ ์นจ์„ ํ•ด๋„ ๋กœ๊ทธ์ธ ์ƒํƒœ๋ฅผ ์œ ์ง€ํ•˜๊ฒŒ ํ•ฉ๋‹ˆ๋‹ค.




Zustand

React ์ƒํƒœ ๊ด€๋ฆฌ๋ฅผ ๊ฐ„๋‹จํ•˜๊ณ  ์ง๊ด€์ ์œผ๋กœ ํ•ด์ฃผ๋Š” ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์ž…๋‹ˆ๋‹ค. Redux์ฒ˜๋Ÿผ ๋ณต์žกํ•œ ์„ค์ • ์—†์ด ์ „์—ญ ์ƒํƒœ๋ฅผ ๋งŒ๋“ค๊ณ  ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์„ค์น˜ ๋ฐฉ๋ฒ•

npm install zustand

์˜ˆ์ œ ์ฝ”๋“œ

import { create } from 'zustand';

interface StoreState {
  isLoggedIn: boolean;
  storeLogin: (token: string) => void;
  storeLogout: () => void;
}

const getToken = () => {
  const token = localStorage.getItem('token');
  return token;
};

const setToken = (token: string) => {
  localStorage.setItem('token', token);
};

const removeToken = () => {
  localStorage.removeItem('token');
};

export const useAuthStore = create<StoreState>((set) => ({
  isLoggedIn: getToken() ? true : false,
  storeLogin: (token: string) => {
    set({ isLoggedIn: true });
    setToken(token);
  },
  storeLogout: () => {
    set({ isLoggedIn: false });
    removeToken();
  },
}));
  • useAuthStore : ๋กœ๊ทธ์ธ ์ƒํƒœ๋ฅผ ์ „์—ญ์œผ๋กœ ๊ด€๋ฆฌํ•˜๋Š” ์ปค์Šคํ…€ ํ›…์ž…๋‹ˆ๋‹ค.

    • create((set) => ({...}) : store(์ƒํƒœ ์ €์žฅ์†Œ)๋ฅผ ๋งŒ๋“œ๋Š” ํ•จ์ˆ˜์ด๋ฉฐ, set์„ ํ†ตํ•ด ์ƒํƒœ๋ฅผ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค.

      • isLoggedIn : ๋กœ๊ทธ์ธ ์ƒํƒœ๋ฅผ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. (true or false)

      • storeLogin : ํ† ํฐ์„ ์ €์žฅํ•˜๊ณ , ๋กœ๊ทธ์ธ ์ƒํƒœ๋กœ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค.

      • storeLogout : ํ† ํฐ์„ ์‚ญ์ œํ•˜๊ณ , ๋กœ๊ทธ์•„์›ƒ ์ƒํƒœ๋กœ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค.




๋„์„œ ๋ชฉ๋ก ํŽ˜์ด์ง€

๋„์„œ ํŽ˜์ด์ง€ ์ปดํฌ๋„ŒํŠธ ๊ตฌ์กฐ

<Books />
โ”œโ”€โ”€ <BookFilter />        โ† ์นดํ…Œ๊ณ ๋ฆฌ ํ•„ํ„ฐ
โ”œโ”€โ”€ <BooksViewSwitcher /> โ† ์ฑ… ๋ทฐ ํ•„ํ„ฐ (๊ทธ๋ฆฌ๋“œ, ๋ฆฌ์ŠคํŠธ)
โ”œโ”€โ”€ <BookList />          โ† ๋„์„œ ๋ชฉ๋ก 
โ”‚   โ”œโ”€โ”€ <BookItem />      โ† ๊ฐœ๋ณ„ ๋„์„œ ์•„์ดํ…œ 1
โ”‚   โ”œโ”€โ”€ <BookItem />      โ† ๊ฐœ๋ณ„ ๋„์„œ ์•„์ดํ…œ 2
โ”‚   โ””โ”€โ”€ <BookItem />      โ† ๊ฐœ๋ณ„ ๋„์„œ ์•„์ดํ…œ 3
โ”‚             โ
โ”œโ”€โ”€ <BookEmpty />         โ† ์ฑ…์ด ์—†์„ ๋•Œ ๋ณด์—ฌ์ฃผ๋Š” UI (์กฐ๊ฑด๋ถ€ ๋ Œ๋”๋ง)
โ””โ”€โ”€ <Pagination />        โ† ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ปจํŠธ๋กค๋Ÿฌ (๋‹ค์Œ/์ด์ „ ํŽ˜์ด์ง€ ์ด๋™)

BookFilter

์ฟผ๋ฆฌ์ŠคํŠธ๋ง(QS) ์—…๋ฐ์ดํŠธ

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”       ๊ฐ ํ•„๋“œ ๋ณ€๊ฒฝ โ†’ QS ์š”์ฒญ
โ”‚  <BooksFilter />  โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                        โ”‚
                                             โ–ผ
      โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
      โ”‚                  /books                         โ”‚
      โ”‚    ?category_id=0 & news=true & view=grid       โ”‚
      โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                              โ”‚
                                              โ–ผ
                   โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                   โ”‚              useBooks           โ”‚
                   โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚
                   โ”‚  โ”‚ 1. QS ๋ณ€๊ฒฝ ๊ฐ์ง€             โ”‚  โ”‚
                   โ”‚  โ”‚ 2. fetch ํ˜ธ์ถœ              โ”‚  โ”‚
                   โ”‚  โ”‚ 3. books ์ƒํƒœ ๊ฐฑ์‹           โ”‚  โ”‚
                   โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚
                   โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                               โ”‚
                                               โ–ผ
                   โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                   โ”‚              <Books />          โ”‚
                   โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚
                   โ”‚  โ”‚  ํ™”๋ฉด ์ƒํƒœ ๊ฐฑ์‹               โ”‚  โ”‚
                   โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚
                   โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

์˜ˆ์‹œ ์ฝ”๋“œ

import styled from 'styled-components';
import { useCategory } from '../../hooks/useCategory';
import Button from '../common/Button';
import { useSearchParams } from 'react-router-dom';

export default function BooksFilter() {
  const { category } = useCategory();
  const [searchParams, setSearchParams] = useSearchParams();
  
  const handleCategory = (id: number | null) => {
    const newSearchParams = new URLSearchParams(searchParams);

    if (id === null) {
      newSearchParams.delete('category_id');
    } else {
      newSearchParams.set('category_id', id.toString());
    }
    setSearchParams(newSearchParams);
  };

  const handleNews = () => {
    const newSearchParams = new URLSearchParams(searchParams);

    if (newSearchParams.get('news')) {
      newSearchParams.delete('news');
    } else {
      newSearchParams.set('news', 'true');
    }
    setSearchParams(newSearchParams);
  };

  return (
    <StyledBooksFilter>
      <div className='category'>
        {category.map((item) => (
          <Button
            size='medium'
            scheme={item.isActive ? 'primary' : 'normal'}
            key={item.id}
            onClick={() => handleCategory(item.id)}
          >
            {item.name}
          </Button>
        ))}
      </div>
      <div className='new'>
        <Button
          size='medium'
          scheme={searchParams.get('news') ? 'primary' : 'normal'}
          onClick={handleNews}
        >
          ์‹ ๊ฐ„
        </Button>
      </div>
    </StyledBooksFilter>
  );
}

const StyledBooksFilter = styled.div`
  display: flex;
  gap: 24px;

  .category {
    display: flex;
    gap: 8px;
  }
`;
  • handleCategory : ์นดํ…Œ๊ณ ๋ฆฌ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด๋ฉด, ํ˜„์žฌ URL์˜ ๊ฒ€์ƒ‰ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ์ƒˆ๋กœ์šด URLSearchParams ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค..

    • id๊ฐ€ null์ด๋ฉด category_id๋ฅผ ์ œ๊ฑฐํ•ฉ๋‹ˆ๋‹ค.

    • ๊ทธ๋ ‡์ง€ ์•Š์œผ๋ฉด id๋ฅผ ๋ฌธ์ž์—ด๋กœ ๋ฐ”๊ฟ”์„œ category_id์— ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.

    => ์ตœ์ข…์ ์œผ๋กœ ๋ณ€๊ฒฝ๋œ ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๋ฐ˜์˜(setSearchParams)์‹œํ‚ต๋‹ˆ๋‹ค.


  • handleNews : ์‹ ๊ฐ„ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด๋ฉด, ํ˜„์žฌ URL์˜ ๊ฒ€์ƒ‰ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ์ƒˆ๋กœ์šด URLSearchParams ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.

    • news ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ์žˆ์œผ๋ฉด ์ œ๊ฑฐํ•˜๊ณ , ์—†์œผ๋ฉด true๋กœ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.

    => ์ตœ์ข…์ ์œผ๋กœ ๋ณ€๊ฒฝ๋œ ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๋ฐ˜์˜(setSearchParams)์‹œํ‚ต๋‹ˆ๋‹ค.


  • return : category ๋ฐฐ์—ด์„ ๋Œ๋ฉด์„œ ๊ฐ๊ฐ์˜ ์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ ๋ฒ„ํŠผ์œผ๋กœ ๋ Œ๋”๋งํ•ฉ๋‹ˆ๋‹ค.

    • ํ•ด๋‹น ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ isActive์ผ ๊ฒฝ์šฐ primary ์Šคํƒ€์ผ์„ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค.

    • ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ ํ•ด๋‹น ์นดํ…Œ๊ณ ๋ฆฌ id๋กœ ํ•„ํ„ฐ๋งํ•ฉ๋‹ˆ๋‹ค.


BooksViewSwitcher

import styled from 'styled-components';
import Button from '../common/Button';
import { FaList, FaTh } from 'react-icons/fa';
import { useSearchParams } from 'react-router-dom';
import { QUERYSTRING } from '../../constants/querystring';
import { useEffect } from 'react';

const viewOptions = [
  {
    value: 'list',
    icon: <FaList />,
  },
  {
    value: 'grid',
    icon: <FaTh />,
  },
];

export type ViewMode = 'grid' | 'list';

export default function BooksViewSwitcher() {
  const [searchParams, setSearchParams] = useSearchParams();

  const handleSwitch = (value: string) => {
    const newSearchParams = new URLSearchParams(searchParams);

    newSearchParams.set(QUERYSTRING.VIEW, value);

    setSearchParams(newSearchParams);
  };

  useEffect(() => {
    if (!searchParams.get(QUERYSTRING.VIEW)) {
      handleSwitch('grid');
    }
  }, []);

  return (
    <StyledBooksViewSwitcher>
      {viewOptions.map((option) => (
        <Button
          key={option.value}
          size='medium'
          scheme={
            searchParams.get(QUERYSTRING.VIEW) === option.value
              ? 'primary'
              : 'normal'
          }
          onClick={() => handleSwitch(option.value)}
        >
          {option.icon}
        </Button>
      ))}
    </StyledBooksViewSwitcher>
  );
}

const StyledBooksViewSwitcher = styled.div`
  display: flex;
  gap: 8px;
  svg {
    fill: #fff;
  }
`;
  • viewOptions : ๋ฆฌ์ŠคํŠธํ˜• ๋ณด๊ธฐ์™€ ๊ทธ๋ฆฌ๋“œํ˜• ๋ณด๊ธฐ์— ๋Œ€ํ•œ ์˜ต์…˜ ๋ฐฐ์—ด์ž…๋‹ˆ๋‹ค.

  • handleSwitch : ์•„์ด์ฝ˜์„ ํด๋ฆญํ–ˆ์„ ๋•Œ ํ˜ธ์ถœ๋˜์–ด URL์˜ view ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๊ฐฑ์‹ ํ•ฉ๋‹ˆ๋‹ค.

    • ํ˜„์žฌ URL์˜ ๊ฒ€์ƒ‰ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ์ƒˆ๋กœ์šด URLSearchParams ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.

    • ํ•ด๋‹น ๋ณด๊ธฐ ๋ฐฉ์‹(grid , list)์„ ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ์— ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

    => ์ตœ์ข…์ ์œผ๋กœ ๋ณ€๊ฒฝ๋œ ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๋ฐ˜์˜(setSearchParams)์‹œํ‚ต๋‹ˆ๋‹ค.

  • useEffect : ์ฒ˜์Œ ํŽ˜์ด์ง€๊ฐ€ ๋ Œ๋”ฉ๋  ๋•Œ ์ฟผ๋ฆฌ์ŠคํŠธ๋ง์— view๊ฐ€ ์—†๋‹ค๋ฉด, grid๋ฅผ ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.


BookList

import styled from 'styled-components';
import BookItem from './BookItem';
import { Book } from '../../models/book.model';
import { useLocation } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { QUERYSTRING } from '../../constants/querystring';
import { ViewMode } from './BooksViewSwitcher';

interface Props {
  books: Book[];
}

export default function BooksList({ books }: Props) {
  const [view, setView] = useState<ViewMode>('grid');
  const location = useLocation();

  useEffect(() => {
    const params = new URLSearchParams(location.search);
    if (params.get(QUERYSTRING.VIEW)) {
      setView(params.get(QUERYSTRING.VIEW) as ViewMode);
    }
  }, [location.search]);

  return (
    <StyledBooksList view={view}>
      {books.map((item) => (
        <BookItem key={item.id} book={item} view={view} />
      ))}
    </StyledBooksList>
  );
}

interface BooksListStyleProps {
  view: ViewMode;
}

const StyledBooksList = styled.div<BooksListStyleProps>`
  display: grid;
  grid-template-columns: ${({ view }) =>
    view === 'grid' ? 'repeat(4, 1fr)' : 'repeat(1, 1fr)'};
  gap: 24px;
`;
  • useState<ViewMode>('grid') : ๊ธฐ๋ณธ๊ฐ’์„ grid๋กœ ํ•ด์„œ ๋ทฐ ๋ชจ๋“œ ์ƒํƒœ๋ฅผ ์„ ์–ธํ•ฉ๋‹ˆ๋‹ค.

  • useLocation() : react-router-dom์˜ ํ›…์œผ๋กœ ํ˜„์žฌ ํŽ˜์ด์ง€์˜ URL์˜ ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค.

๐Ÿ” useLocation() ๋” ์•Œ์•„๋ณด๊ธฐ

URL : http://localhost:3000/books?view=grid

  • hash: URL์˜ ํ•ด์‹œ(#) ๋ถ€๋ถ„
  • key: URL ์ƒํƒœ์˜ ๊ณ ์œ  ๊ฐ’ (location์˜ ์ƒํƒœ ๋ณ€๊ฒฝ์„ ์ถ”์ )
  • pathname: ํ˜„์žฌ ๊ฒฝ๋กœ
  • search: URL์˜ ์ฟผ๋ฆฌ ๋ฌธ์ž์—ด
  • state : ์ƒํƒœ๊ฐ’ (์—ฌ๊ธฐ์„œ๋Š” ์„ค์ •๋˜์ง€ ์•Š์Œ)

๐Ÿค” useSearchParams()์™€ useLocation() ์˜ ์ฐจ์ด๋Š” ๋ญ˜๊นŒ?

โ–ช๏ธ useSearchParams โ†’ searchParams.get('key') / setSearchParams(...)
โ†’ ์ฟผ๋ฆฌ์ŠคํŠธ๋ง์„ ์ง์ ‘ ์กฐ์ž‘ํ•˜๊ณ  ์‹ถ์„ ๋•Œ

โ–ช๏ธ useLocation โ†’ location.pathname, location.search, location.hash
โ†’ ํ˜„์žฌ URL ์ •๋ณด๋ฅผ ์ฝ๊ธฐ๋งŒ ํ•˜๊ณ  ์‹ถ์„ ๋•Œ


  • useEffect : location.search(์ฟผ๋ฆฌ์ŠคํŠธ๋ง)์ด ๋ฐ”๋€” ๋•Œ๋งˆ๋‹ค ์‹คํ–‰๋˜๋Š” ํ•จ์ˆ˜๋กœ URL์—์„œ view ์ฟผ๋ฆฌ๊ฐ’์ด ์žˆ๋Š”์ง€ ํ™•์ธํ•˜๊ณ , ์žˆ์œผ๋ฉด ๊ทธ ๊ฐ’์„ ํ˜„์žฌ ์ƒํƒœ๋กœ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.

  • return : books ๋ฐฐ์—ด์„ ์ˆœํšŒํ•˜๋ฉฐ BookItem์„ ํ•˜๋‚˜์”ฉ ์ถœ๋ ฅํ•˜๊ณ , ๊ฐ ์ฑ… ์ •๋ณด(book)์™€ ํ˜„์žฌ ๋ทฐ(view)๋ฅผ ์ „๋‹ฌํ•ฉ๋‹ˆ๋‹ค.


BookItem

import styled from 'styled-components';
import { Book } from '../../models/book.model';
import { getImgSrc } from '../../utils/image';
import { formatNumber } from '../../utils/format';
import { FaHeart } from 'react-icons/fa';
import { Theme } from '../../style/theme';
import { ViewMode } from './BooksViewSwitcher';

interface Props {
  book: Book;
  view?: ViewMode;
}

export default function BookItem({ book, view }: Props) {
  return (
    <StyledBookItem view={view}>
      <div className='img'>
        <img src={getImgSrc(book.img)} alt={book.title} />
      </div>
      <div className='content'>
        <h2 className='title'>{book.title}</h2>
        <p className='summary'>{book.summary}</p>
        <p className='author'>{book.author}</p>
        <p className='price'>{formatNumber(book.price)}์›</p>
        <div className='likes'>
          <FaHeart />
          <span>{book.likes}</span>
        </div>
      </div>
    </StyledBookItem>
  );
}

const StyledBookItem = styled.div<Pick<Props, 'view'>>`
  display: flex;
  flex-direction: ${({ view }) => (view === 'grid' ? 'column' : 'row')};
  box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);

  .img {
    border-radius: ${({ theme }) => (theme as Theme).borderRadius.default};
    overflow: hidden;
    width: ${({ view }) => (view === 'grid' ? 'auto' : '160px')};
    img {
      max-width: 100%;
    }
  }

  .content {
    padding: 16px;
    position: relative;
    flex: ${({ view }) => (view === 'grid' ? 0 : 1)};

    .title {
      font-size: 1.25rem;
      font-weight: 700;
      margin: 0 0 12px 0;
    }
    .summary {
      font-size: 0.875rem;
      color: ${({ theme }) => (theme as Theme).color.background};
      margin: 0 0 4px 0;
    }
    .author {
      font-size: 0.875rem;
      color: ${({ theme }) => (theme as Theme).color.background};
      margin: 0 0 4px 0;
    }
    .price {
      font-size: 1rem;
      color: ${({ theme }) => (theme as Theme).color.background};
      margin: 0 0 4px 0;
      font-weight: 700;
    }
    .likes {
      display: inline-flex;
      align-items: center;
      gap: 4px;
      font-size: 0.875rem;
      color: ${({ theme }) => (theme as Theme).color.primary};
      margin: 0 0 4px 0;
      font-weight: 700;
      border: 1px solid ${({ theme }) => (theme as Theme).color.border};
      border-radius: ${({ theme }) => (theme as Theme).borderRadius.default};
      padding: 4px 12px;
      position: absolute;
      bottom: 16px;
      right: 16px;

      svg {
        color: ${({ theme }) => (theme as Theme).color.primary};
      }
    }
  }
`;
  • Props : book์€ ์ฑ…์˜ ๋ฐ์ดํ„ฐ๋ฅผ ๋‹ด๊ณ  ์žˆ์œผ๋ฉฐ, view๋Š” ์„ ํƒ์  ํ”„๋กœํผํ‹ฐ๋กœ ๋ณด๊ธฐ ๋ชจ๋“œ๋ฅผ ๋‚˜ํƒ€๋ƒ…๋‹ˆ๋‹ค.

  • book ๊ฐ์ฒด์˜ ์—ฌ๋Ÿฌ ์†์„ฑ๊ฐ’์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์ฑ…์˜ ์ •๋ณด๋ฅผ ํ™”๋ฉด์— ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค.


BookEmpty

import { FaSmileWink } from 'react-icons/fa';
import styled from 'styled-components';
import Title from '../common/Title';
import { Link } from 'react-router-dom';

export default function BooksEmpty() {
  return (
    <StyledBooksEmpty>
      <div className='icon'>
        <FaSmileWink />
      </div>
      <Title size='large' color='secondary'>
        ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.
      </Title>
      <p>
        <Link to='/books'>์ „์ฒด ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๋กœ ์ด๋™</Link>
      </p>
    </StyledBooksEmpty>
  );
}

const StyledBooksEmpty = styled.div`
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  gap: 12px;
  padding: 120px 0;

  .icon {
    svg {
      font-size: 4rem;
      fill: #ccc;
    }
  }
`;
  • Link : '/books' ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•˜๋Š” ๋งํฌ๋ฅผ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค.

Pagination

import styled from 'styled-components';
import { Pagination as IPagination } from '../../models/pagination.model';
import { LIMIT } from '../../constants/pagination';
import Button from '../common/Button';
import { useSearchParams } from 'react-router-dom';
import { QUERYSTRING } from '../../constants/querystring';

interface Props {
  pagination: IPagination;
}

export default function Pagination({ pagination }: Props) {
  const [searchParams, setSearchParams] = useSearchParams();
  const { totalCount, currentPage } = pagination;
  const pages: number = Math.ceil(totalCount / LIMIT);

  const handleClickPage = (page: number) => {
    const newSearchParams = new URLSearchParams(searchParams);

    newSearchParams.set(QUERYSTRING.PAGE, page.toString());

    setSearchParams(newSearchParams);
  };

  return (
    <StyledPagination>
      {pages > 0 && (
        <ol>
          {Array(pages)
            .fill(0)
            .map((_, index) => (
              <li>
                <Button
                  key={index}
                  size='small'
                  scheme={index + 1 === currentPage ? 'primary' : 'normal'}
                  onClick={() => handleClickPage(index + 1)}
                >
                  {index + 1}
                </Button>
              </li>
            ))}
        </ol>
      )}
    </StyledPagination>
  );
}

const StyledPagination = styled.div`
  display: flex;
  justify-content: start;
  align-items: center;
  padding: 24px 0;

  ol {
    list-style: none;
    display: flex;
    gap: 8px;
    padding: 0;
    margin: 0;
  }
`;
  • Props : IPagination ํƒ€์ž…์œผ๋กœ ์ง€์ •ํ•˜์—ฌ ๋ณ€์ˆ˜๊ฐ€ ํ—ท๊ฐˆ๋ฆฌ์ง€ ์•Š๊ฒŒ ํ•ฉ๋‹ˆ๋‹ค.

  • handleClickPage : ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ๋ฅผ ํด๋ฆญํ–ˆ์„ ๋•Œ ํ˜ธ์ถœ๋˜์–ด URL์˜ page ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๊ฐฑ์‹ ํ•ฉ๋‹ˆ๋‹ค.

    • ํ˜„์žฌ URL์˜ ๊ฒ€์ƒ‰ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ์ƒˆ๋กœ์šด URLSearchParams ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.

    • ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ๋ฅผ ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ์— ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

    => ์ตœ์ข…์ ์œผ๋กœ ๋ณ€๊ฒฝ๋œ ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๋ฐ˜์˜(setSearchParams)์‹œํ‚ต๋‹ˆ๋‹ค.




๋„์„œ ๋ชฉ๋ก ํŽ˜์ด์ง€ ๊ฒฐ๊ณผ ํ™”๋ฉด




โœ๏ธ ํšŒ๊ณ 

์ „์—ญ ์ƒํƒœ๋ฅผ ๊ด€๋ฆฌํ•˜๋Š” ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ ๋งŽ์ง€๋งŒ, zustand๊ฐ€ ์ต์ˆ™์น˜ ์•Š๋‹ค๋Š” ๊ฒƒ ๋ง๊ณ ๋Š” ์‚ฌ์šฉํ•˜๊ธฐ์—” ํ›จ์”ฌ ํŽธํ•œ ๊ฒƒ ๊ฐ™๋‹ค. ๋„์„œ ํŽ˜์ด์ง€๋ฅผ ๋งŒ๋“ค๋ฉด์„œ ์ปดํฌ๋„ŒํŠธ๋กœ ๋‚˜๋ˆ„๊ณ , ํ•˜๋‚˜ํ•˜๋‚˜์”ฉ ์—ฐ๊ฒฐ์‹œํ‚ค๋ฉด์„œ ์ƒ๊ฐํ•˜๋Š” ๊ฒŒ ์•„์ง์€ ํž˜๋“ค๊ณ , ํƒ€์ž… ์ง€์ •ํ•˜๋Š” ๊ฒƒ์— ๋” ์ต์ˆ™ํ•ด์ ธ์•ผ ํ•  ๊ฒƒ ๊ฐ™๋‹ค.

profile
๐ŸŒฑ๊ฐœ๋ฐœ ๊ธฐ๋ก์žฅ

0๊ฐœ์˜ ๋Œ“๊ธ€