2024.02.22(목)

💡Theme Switcher with Context API

  1. 사용자는 토글 UI를 통해 웹사이트의 색상 테마 변경 가능
  2. 색상 테마는 전역 상태로 존재
  3. 사용자가 선택한 테마는 로컬 스토리지에 저장
  • src/index.tsx

    import React from 'react';
    import ReactDOM from 'react-dom/client';
    import App from './App';
    
    const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
    root.render(
      <React.StrictMode>
          <App />
      </React.StrictMode>
    );
  • src/App.tsx

    import Layout from "./components/layout/Layout";
    import Detail from "./pages/Detail";
    import Home from "./pages/Home";
    import ThemeSwitcher from "./components/header/ThemeSwitcher";
    import { BookStoreThemeProvider } from "./context/themeContext";
    
    function App() {
      return (
        <BookStoreThemeProvider>
            <ThemeSwitcher />
            <Layout>
              <Home />
              {/* <Detail /> */}
            </Layout>
        </BookStoreThemeProvider>
      );
    }
    
    export default App;
  • src/components/header/ThemeSwitcher.tsx

    • Context API인 ThemeContext에 의존

      import { useContext } from "react";
      import { ThemeContext } from "../../context/themeContext";
      
      function ThemeSwitcher() {
          const { themeName, toggleTheme } = useContext(ThemeContext)
      
          return (
              <button onClick={toggleTheme}>{themeName}</button>
          );
      }
      
      export default ThemeSwitcher;
  • src/context/themeContext.tsx

    • ThemeContext 생성 & 초기값 설정

    • BookStoreThemeProvider라는 component 생성

      • localStorage에 현재 themeName을 저장하고 마운트 시 가져오는 작업 수행
      • ThemeContext.Provider로 감싸서 전달하는 value를 children, 즉 ThemeSwitcher에서 useContext Hook으로 사용할 수 있도록 함
      • global 및 theme 스타일드 컴포넌트에 themeName을 전달해서 모든 theme 관련 코드를 이 파일에 모아서 작성
      import { ReactNode, createContext, useEffect, useState } from "react";
      import { ThemeName, getTheme } from "../style/theme";
      import { ThemeProvider } from "styled-components";
      import { GlobalStyle } from "../style/global";
      
      const DEFAULT_THEME_NAME = "light";
      const THEME_LOCALSTORAGE_KEY = "book_store_theme";
      
      interface State {
          themeName: ThemeName;
          toggleTheme: () => void;
      }
      
      export const state = {
          themeName: DEFAULT_THEME_NAME as ThemeName,
          toggleTheme: () => {}
      };
      
      export const ThemeContext = createContext<State>(state);
      
      export const BookStoreThemeProvider = ({ children }: { children: ReactNode }) => {
          const [themeName, setThemeName] = useState<ThemeName>(DEFAULT_THEME_NAME);
      
          const toggleTheme = () => {
              setThemeName(themeName === "light" ? "dark" : "light");
              localStorage.setItem(THEME_LOCALSTORAGE_KEY, themeName === "light" ? "dark" : "light");
          };
      
          useEffect(() => {
              const savedThemeName = localStorage.getItem(THEME_LOCALSTORAGE_KEY) as ThemeName;
              setThemeName(savedThemeName || themeName);
          }, []);
      
          return (
              <ThemeContext.Provider value={{ themeName, toggleTheme }}>
                  <ThemeProvider theme={getTheme(themeName)}>
                      <GlobalStyle themeName={themeName} />
                      {children}
                  </ThemeProvider>
              </ThemeContext.Provider>
          );
      };

🌱기본 컴포넌트 작성

📣Title

  • src/style/theme.ts

    • HeadingSize, interface Theme에 heading 부분, light과 dark에 heading 객체 추가

    • dark에서 heading 정보는 공통이므로 light를 spread 연산자로 구조분해 할당한 후 name과 color만 오버라이딩

      export type ThemeName = "light" | "dark";
      export type ColorKey = "primary" | "background" | "secondary" | "third";
      export type HeadingSize = "large" | "medium" | "small";
      
      interface Theme {
          name: ThemeName;
          color: Record<ColorKey, string>;
          heading: {
              [key in HeadingSize]: {
                  fontSize: string;
              }
          };
      }
      
      export const light: Theme = {
          name: "light",
          color: {
              primary: "brown",
              background: "lightgray",
              secondary: "blue",
              third: "green",
          },
          heading: {
              large: {
                  fontSize: "2rem"
              },
              medium: {
                  fontSize: "1.5rem"
              },
              small: {
                  fontSize: "1rem"
              }
          }
      };
      
      export const dark: Theme = {
          ...light,
          name: "dark",
          color: {
              primary: "coral",
              background: "midnightblue",
              secondary: "lightblue",
              third: "lightgreen",
          }
      };
      
      export const getTheme = (themeName: ThemeName): Theme => {
          switch (themeName) {
              case "light":
                  return light;
              case "dark":
                  return dark;
          }
      };
  • src/components/common/Title.tsx

    • Utility Types: Pick<Type, Keys>

      Type에서 Keys(string literal or union of string literals)에 전달된 프로퍼티를 골라서 타입을 구성

    • Utility Types: Omit<Type, Keys>

      Type에서 Keys(string literal or union of string literals)에 전달된 프로퍼티는 제외하고 타입을 구성, Pick의 반대

      import styled from "styled-components";
      import { ColorKey, HeadingSize } from "../../style/theme";
      
      interface Props {
          children: React.ReactNode;
          size: HeadingSize;
          color?: ColorKey;
      }
      
      function Title({ children, size, color }: Props) {
          return (
              <TitleStyle size={size} color={color}>
                  {children}
              </TitleStyle>
          );
      }
      
      const TitleStyle = styled.h1<Omit<Props, "children">>`
          font-size: ${({ theme, size }) => theme.heading[size].fontSize};
          color: ${({ theme, color }) => theme.color[color || "primary"]};
      `;
      
      export default Title;
  • src/pages/Home.tsx

    import Title from "../components/common/Title";
    
    function Home() {
        return (
            <>
                <Title size="medium" color="secondary">
                    제목 테스트
                </Title>
                <div>home</div>
            </>
        );
    }
    
    export default Home;
  • 🧪src/components/common/Title.spec.tsx

    • 간단한 테스트 코드

    • theme을 전달해주기 위해 정의해둔 BookStoreThemeProvider로 감싸주어야 함

    • screen.getByText

      • 주어진 텍스트를 가진 요소를 찾아 반환
      • 화면에 보이는 텍스트를 기준으로 요소를 선택 가능
      import { render, screen } from '@testing-library/react';
      import Title from './Title';
      import { BookStoreThemeProvider } from '../../context/themeContext';
      
      describe("Title 컴포넌트 테스트", () => {
          it("렌더 확인", () => {
              render(
                  <BookStoreThemeProvider>
                      <Title size="large">제목</Title>
                  </BookStoreThemeProvider>
              );
      
              expect(screen.getByText("제목")).toBeInTheDocument();
          });
      
          it("size props 적용", () => {
              const { container } = render(
                  <BookStoreThemeProvider>
                      <Title size="large">제목</Title>
                  </BookStoreThemeProvider>
              );
      
              expect(container?.firstChild).toHaveStyle({
                  fontSize: "2rem"
              })
          });
      
          it("color props 적용", () => {
              const { container } = render(
                  <BookStoreThemeProvider>
                      <Title size="large" color="primary">제목</Title>
                  </BookStoreThemeProvider>
              );
      
              expect(container?.firstChild).toHaveStyle({
                  color: "brown"
              })
          });
      });
    • npm run test Title

       PASS  src/components/common/Title.spec.tsx
        Title 컴포넌트 테스트
          √ 렌더 확인 (22 ms)
          √ size props 적용 (14 ms)
          √ color props 적용 (6 ms)
      
      Test Suites: 1 passed, 1 total
      Tests:       3 passed, 3 total
      Snapshots:   0 total
      Time:        2.562 s

🕹️Button

  • src/style/theme.ts

    • ButtonSize , ButtonScheme, borderRadius 추가 & 관련 interface, 프로퍼티 추가

      export type ThemeName = "light" | "dark";
      export type ColorKey = "primary" | "background" | "secondary" | "third";
      export type HeadingSize = "large" | "medium" | "small";
      export type ButtonSize = "large" | "medium" | "small";
      export type ButtonScheme = "primary" | "normal";
      
      interface Theme {
          name: ThemeName;
          color: Record<ColorKey, string>;
          heading: {
              [key in HeadingSize]: {
                  fontSize: string;
              }
          };
          button: {
              [key in ButtonSize]: {
                  fontSize: string;
                  padding: string;
              }
          };
          buttonScheme: {
              [key in ButtonScheme]: {
                  color: string;
                  backgroundColor: string;
              }
          };
          borderRadius: {
              default: string;
          };
      }
      
      export const light: Theme = {
          name: "light",
          color: {
              primary: "brown",
              background: "lightgray",
              secondary: "blue",
              third: "green",
          },
          heading: {
              large: {
                  fontSize: "2rem"
              },
              medium: {
                  fontSize: "1.5rem"
              },
              small: {
                  fontSize: "1rem"
              }
          },
          button: {
              large: {
                  fontSize: "1.5rem",
                  padding: "1rem 2rem"
              },
              medium: {
                  fontSize: "1rem",
                  padding: "0.5rem 1rem"
              },
              small: {
                  fontSize: "0.75rem",
                  padding: "0.25rem 0.5rem"
              }
          },
          buttonScheme: {
              primary: {
                  color: "white",
                  backgroundColor: "midnightblue"
              },
              normal: {
                  color: "black",
                  backgroundColor: "lightgrey"
              }
          },
          borderRadius: {
              default: "4px"
          }
      };
      
      export const dark: Theme = {
          ...light,
          name: "dark",
          color: {
              primary: "coral",
              background: "midnightblue",
              secondary: "lightblue",
              third: "lightgreen",
          }
      };
      
      export const getTheme = (themeName: ThemeName): Theme => {
          switch (themeName) {
              case "light":
                  return light;
              case "dark":
                  return dark;
          }
      };
  • src/components/common/Button.tsx

    import styled from "styled-components";
    import { ButtonScheme, ButtonSize } from "../../style/theme";
    
    interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
        children: React.ReactNode;
        size: ButtonSize;
        scheme: ButtonScheme;
        disabled?: boolean;
        isLoading?: boolean;
    }
    
    function Button({ children, size, scheme, disabled, isLoading, ...props }: Props) {
        return (
            <ButtonStyle size={size} scheme={scheme} disabled={disabled} isLoading={isLoading} {...props}>
                {children}
            </ButtonStyle>
        );
    }
    
    const ButtonStyle = styled.button<Omit<Props, "children">>`
        font-size: ${({ theme, size }) => theme.button[size].fontSize};
        padding: ${({ theme, size }) => theme.button[size].padding};
        color: ${({ theme, scheme }) => theme.buttonScheme[scheme].color};
        background-color: ${({ theme, scheme }) => theme.buttonScheme[scheme].backgroundColor};
        border: 0;
        border-radius: ${({ theme }) => theme.borderRadius.default};
        opacity: ${({ disabled }) => disabled ? 0.5 : 1};
        pointer-events: ${({ disabled }) => disabled ? "none" : "auto"};
        cursor: ${({ disabled }) => disabled ? "none" : "pointer"};
    `;
    
    export default Button;
  • src/pages/Home.tsx

    import Button from "../components/common/Button";
    import Title from "../components/common/Title";
    
    function Home() {
        return (
            <>
                <Title size="medium" color="secondary">
                    제목 테스트
                </Title>
                <Button size="large" scheme="normal">
                    버튼 테스트
                </Button>
                <div>home</div>
            </>
        );
    }
    
    export default Home;
  • 🧪src/components/common/Button.spec.tsx

    • screen.getByRole

      /* eslint-disable testing-library/no-node-access */
      import { render, screen } from '@testing-library/react';
      import { BookStoreThemeProvider } from '../../context/themeContext';
      import Button from './Button';
      
      describe("Button 컴포넌트 테스트", () => {
          it("렌더 확인", () => {
              render(
                  <BookStoreThemeProvider>
                      <Button size="large" scheme="primary">버튼</Button>
                  </BookStoreThemeProvider>
              );
      
              expect(screen.getByText("버튼")).toBeInTheDocument();
          });
      
          it("size 및 scheme props 적용", () => {
              render(
                  <BookStoreThemeProvider>
                      <Button size="large" scheme="primary">버튼</Button>
                  </BookStoreThemeProvider>
              );
      
              expect(screen.getByRole("button")).toHaveStyle({
                  fontSize: "1.5rem",
                  padding: "1rem 2rem",
                  color: "white",
                  backgroundColor: "midnightblue"
              })
          });
      
          it("disabled props 적용", () => {
              render(
                  <BookStoreThemeProvider>
                      <Button size="large" scheme="primary" disabled={true}>버튼</Button>
                  </BookStoreThemeProvider>
              );
      
              expect(screen.getByRole("button")).toHaveStyle({
                  opacity: 0.5,
                  "pointer-events": "none",
                  cursor: "none"
              })
          });
      });
    • npm run test Button

       PASS  src/components/common/Button.spec.tsx
        Button 컴포넌트 테스트
          √ 렌더 확인 (22 ms)
          √ size 및 scheme props 적용 (24 ms)
          √ disabled props 적용 (12 ms)
      
      Test Suites: 1 passed, 1 total
      Tests:       3 passed, 3 total
      Snapshots:   0 total
      Time:        1.399 s

🔤Input

  • src/style/theme.ts

    • ColorKey에 border와 text 추가 & 관련 interface, 프로퍼티 추가

      export type ThemeName = "light" | "dark";
      export type ColorKey = "primary" | "background" | "secondary" | "third" | "border" | "text";
      export type HeadingSize = "large" | "medium" | "small";
      export type ButtonSize = "large" | "medium" | "small";
      export type ButtonScheme = "primary" | "normal";
      
      interface Theme {
          name: ThemeName;
          color: Record<ColorKey, string>;
          heading: {
              [key in HeadingSize]: {
                  fontSize: string;
              }
          };
          button: {
              [key in ButtonSize]: {
                  fontSize: string;
                  padding: string;
              }
          };
          buttonScheme: {
              [key in ButtonScheme]: {
                  color: string;
                  backgroundColor: string;
              }
          };
          borderRadius: {
              default: string;
          };
      }
      
      export const light: Theme = {
          name: "light",
          color: {
              primary: "brown",
              background: "lightgray",
              secondary: "blue",
              third: "green",
              border: "grey",
              text: "black"
          },
          heading: {
              large: {
                  fontSize: "2rem"
              },
              medium: {
                  fontSize: "1.5rem"
              },
              small: {
                  fontSize: "1rem"
              }
          },
          button: {
              large: {
                  fontSize: "1.5rem",
                  padding: "1rem 2rem"
              },
              medium: {
                  fontSize: "1rem",
                  padding: "0.5rem 1rem"
              },
              small: {
                  fontSize: "0.75rem",
                  padding: "0.25rem 0.5rem"
              }
          },
          buttonScheme: {
              primary: {
                  color: "white",
                  backgroundColor: "midnightblue"
              },
              normal: {
                  color: "black",
                  backgroundColor: "lightgrey"
              }
          },
          borderRadius: {
              default: "4px"
          }
      };
      
      export const dark: Theme = {
          ...light,
          name: "dark",
          color: {
              primary: "coral",
              background: "midnightblue",
              secondary: "lightblue",
              third: "lightgreen",
              border: "grey",
              text: "white"
          }
      };
      
      export const getTheme = (themeName: ThemeName): Theme => {
          switch (themeName) {
              case "light":
                  return light;
              case "dark":
                  return dark;
          }
      };
  • src/components/common/InputText.tsx

    • React.forwardRef

      • React 컴포넌트를 반환하는 함수로, ref를 자식 컴포넌트로 전달하는 데 사용됨
      • 🤖ChatGPT의 설명
        코드에서 React.forwardRef()를 사용하는 이유는 InputText 컴포넌트가 styled-components를 사용하여 스타일링되었고, 해당 스타일된 입력 요소에 ref를 직접 전달해야하기 때문입니다.
        styled-components를 사용하여 스타일링된 컴포넌트에는 내부적으로 생성된 DOM 요소에 대한 접근이 필요할 수 있습니다. 예를 들어, 포커스를 설정하거나 특정 DOM 조작을 수행해야 할 때가 있습니다. 이런 경우에는 해당 요소에 ref를 전달해야 합니다.
        그러나 styled-components의 스타일링된 컴포넌트는 해당 요소에 대한 참조를 자동으로 전달하지 않습니다. 따라서, React.forwardRef()를 사용하여 명시적으로 ref를 전달할 수 있도록 해야 합니다. 이렇게 하면 부모 컴포넌트에서 InputText를 사용할 때 ref를 사용하여 해당 입력 요소에 직접적으로 접근할 수 있습니다.
        따라서 React.forwardRef()를 사용하여 InputText 컴포넌트를 감싸고, 해당 컴포넌트의 ref를 전달할 수 있도록 합니다. 이를 통해 스타일링된 입력 요소에 대한 외부에서의 ref 접근이 가능해집니다.
      import React, { ForwardedRef } from "react";
      import styled from "styled-components";
      
      interface Props {
          placeholder?: string;
      }
      
      const InputText = React.forwardRef(({ placeholder }: Props, ref: ForwardedRef<HTMLInputElement>) => {
          return (
              <InputTextStyle placeholder={placeholder} ref={ref} />
          );
      });
      
      const InputTextStyle = styled.input.attrs({ type: "text" })`
          padding: 0.25rem 0.75rem;
          border: 1px solid ${({ theme }) => theme.color.border};
          border-radius: ${({ theme }) => theme.borderRadius.default};
          font-size: 1rem;
          line-height: 1.5;
          color: ${({ theme }) => theme.color.text};
      `;
      
      export default InputText;
  • src/pages/Home.tsx

    import Button from "../components/common/Button";
    import InputText from "../components/common/InputText";
    import Title from "../components/common/Title";
    
    function Home() {
        return (
            <>
                <Title size="medium" color="secondary">
                    제목 테스트
                </Title>
                <Button size="large" scheme="normal">
                    버튼 테스트
                </Button>
                <InputText placeholder="여기에 입력하세요" />
                <div>home</div>
            </>
        );
    }
    
    export default Home;
  • 🧪src/components/common/Button.spec.tsx

    • screen.getByPlaceholderText

      • 주어진 placeholder 텍스트를 가진 요소를 찾아 반환
      • 주로 input 요소의 placeholder를 기준으로 요소를 선택할 때 사용
      /* eslint-disable testing-library/no-node-access */
      import React from 'react';
      import { render, screen } from '@testing-library/react';
      import { BookStoreThemeProvider } from '../../context/themeContext';
      import InputText from './InputText';
      
      describe("Button 컴포넌트 테스트", () => {
          it("렌더 확인", () => {
              render(
                  <BookStoreThemeProvider>
                      <InputText placeholder="여기에 입력" />
                  </BookStoreThemeProvider>
              );
      
              expect(screen.getByPlaceholderText("여기에 입력")).toBeInTheDocument();
          });
      
          it("forwardRef 테스트", () => {
              const ref = React.createRef<HTMLInputElement>();
      
              render(
                  <BookStoreThemeProvider>
                      <InputText placeholder="여기에 입력" ref={ref} />
                  </BookStoreThemeProvider>
              );
      
              expect(ref.current).toBeInstanceOf(HTMLInputElement)
          });
      });
    • npm run test InputText

       PASS  src/components/common/InputText.spec.tsx
        Button 컴포넌트 테스트
          √ 렌더 확인 (15 ms)
          √ forwardRef 테스트 (2 ms)
      
      Test Suites: 1 passed, 1 total
      Tests:       2 passed, 2 total
      Snapshots:   0 total
      Time:        1.532 s

📚프로젝트 3 : BOOK STORE

🛠️헤더와 푸터 구성

  • icon은 React Icons 사용: npm install react-icons --save
  • src\components\common\Header.tsx
    import styled from "styled-components";
    import logo from "../../assets/images/logo.png"
    import { FaSignInAlt, FaRegUser } from "react-icons/fa"
    import ThemeSwitcher from "../header/ThemeSwitcher";
    
    const CATEGORY = [
        {
            id: null,
            name: "전체"
        },
        {
            id: 0,
            name: "동화"
        },
        {
            id: 1,
            name: "소설"
        },
        {
            id: 2,
            name: "사회"
        },
    ];
    
    function Header() {
        return (
            <HeaderStyle>
                <h1 className="logo">
                    <img src={logo} alt="book store" />
                </h1>
                <nav className="category">
                    <ul>
                        {
                            CATEGORY.map((item) => (
                                <li key={item.id}>
                                    <a href={item.id === null ? '/books' : `/books?category_id=${item.id}`}>
                                        {item.name}
                                    </a>
                                </li>
                            ))
                        }
                    </ul>
                </nav>
                <nav className="auth">
                    <ul>
                        <li>
                            <ThemeSwitcher />
                        </li>
                        <li>
                            <a href="/login">
                                <FaSignInAlt />로그인
                            </a>
                        </li>
                        <li>
                            <a href="/signup">
                                <FaRegUser />회원가입
                            </a>
                        </li>
                    </ul>
                </nav>
            </HeaderStyle>
        );
    }
    
    const HeaderStyle = styled.header`
        width: 100%;
        margin: 0 auto;
        max-width: ${({ theme }) => theme.layout.width.large};
    
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding: 20px 0;
        border-bottom: 1px solid ${({ theme }) => theme.color.background};
    
        .logo {
            img {
                width: 200px;
            }
        }
    
        .category {
            ul {
                display: flex;
                gap: 32px;
                li {
                    a {
                        font-size: 1.5rem;
                        font-weight: 600;
                        text-decoration: none;
                        color: ${({ theme }) => theme.color.text};
    
                        &:hover {
                            color: ${({ theme }) => theme.color.primary};
                        }
                    }
                }
            }
        }
    
        .auth {
            ul {
                display: flex;
                gap: 16px;
                li {
                    a {
                        font-size: 1rem;
                        font-weight: 600;
                        text-decoration: none;
                        display: flex;
                        align-items: center;
                        line-height: 1;
    
                        svg {
                            margin-right: 6px;
                        }
                    }
                }
            }
        }
    `;
    
    export default Header;
  • src\components\common\Footer.tsx
    import styled from "styled-components";
    import logo from "../../assets/images/logo.png"
    
    function Footer() {
        return (
            <FooterStyle>
                <h1 className="logo">
                    <img src={logo} alt="book store" />
                </h1>
                <div className="copyright">
                    <p>
                        copyright(c), 2024, Book Store.
                    </p>
                </div>
            </FooterStyle>
        );
    }
    
    const FooterStyle = styled.footer`
        width: 100%;
        margin: 0 auto;
        max-width: ${({ theme }) => theme.layout.width.large};
        
        display: flex;
        justify-content: space-between;
        padding: 20px 0;
        border-top: 1px solid ${({ theme }) => theme.color.background};
    
        .logo {
            img {
                width: 140px;
            }
        }
    
        .copyright {
            p {
                font-size: 0.75rem;
                color: ${({ theme }) => theme.color.text};
            }
        }
    `;
    
    export default Footer;

🛣️라우트 작성

  • React Router 사용: npm install react-router-dom @types/react-router-dom --save

createBrowserRouter

  • Route 객체 리스트를 인수로 받음
    • path의 URL과 일치하면 element를 render
    • loader는 route를 render하기 전에 호출되며 element에게 useLoaderData를 통해 데이터 제공
    • error가 발생하면 errorElement를 render
  • children 프로퍼티로 nested routes 가능
createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    loader: rootLoader,
    children: [
      {
        path: "events/:id",
        element: <Event />,
        loader: eventLoader,
      },
    ],
  },
]);

RouterProvider

  • 이 컴포넌트를 통해 router 제공
ReactDOM.createRoot(document.getElementById("root")).render(
  <RouterProvider
    router={router}
    fallbackElement={<BigSpinner />}
  />
);

간단히 적용해보기

  • src\components\common\Header.tsx

    • <a href=""></a><Link to=""></Link>로 교체 (화면 깜빡임 제거)

      import styled from "styled-components";
      import logo from "../../assets/images/logo.png"
      import { FaSignInAlt, FaRegUser } from "react-icons/fa"
      import ThemeSwitcher from "../header/ThemeSwitcher";
      import { Link } from "react-router-dom";
      
      const CATEGORY = [
          {
              id: null,
              name: "전체"
          },
          {
              id: 0,
              name: "동화"
          },
          {
              id: 1,
              name: "소설"
          },
          {
              id: 2,
              name: "사회"
          },
      ];
      
      function Header() {
          return (
              <HeaderStyle>
                  <h1 className="logo">
                      <Link to="/">
                          <img src={logo} alt="book store" />
                      </Link>
                  </h1>
                  <nav className="category">
                      <ul>
                          {
                              CATEGORY.map((item) => (
                                  <li key={item.id}>
                                      <Link to={item.id === null ? '/books' : `/books?category_id=${item.id}`}>
                                          {item.name}
                                      </Link>
                                  </li>
                              ))
                          }
                      </ul>
                  </nav>
                  <nav className="auth">
                      <ul>
                          <li>
                              <ThemeSwitcher />
                          </li>
                          <li>
                              <Link to="/login">
                                  <FaSignInAlt />로그인
                              </Link>
                          </li>
                          <li>
                              <Link to="/signup">
                                  <FaRegUser />회원가입
                              </Link>
                          </li>
                      </ul>
                  </nav>
              </HeaderStyle>
          );
      }
      
      const HeaderStyle = styled.header`
          width: 100%;
          margin: 0 auto;
          max-width: ${({ theme }) => theme.layout.width.large};
      
          display: flex;
          justify-content: space-between;
          align-items: center;
          padding: 20px 0;
          border-bottom: 1px solid ${({ theme }) => theme.color.background};
      
          .logo {
              img {
                  width: 200px;
              }
          }
      
          .category {
              ul {
                  display: flex;
                  gap: 32px;
                  li {
                      a {
                          font-size: 1.5rem;
                          font-weight: 600;
                          text-decoration: none;
                          color: ${({ theme }) => theme.color.text};
      
                          &:hover {
                              color: ${({ theme }) => theme.color.primary};
                          }
                      }
                  }
              }
          }
      
          .auth {
              ul {
                  display: flex;
                  gap: 16px;
                  li {
                      a {
                          font-size: 1rem;
                          font-weight: 600;
                          text-decoration: none;
                          display: flex;
                          align-items: center;
                          line-height: 1;
      
                          svg {
                              margin-right: 6px;
                          }
                      }
                  }
              }
          }
      `;
      
      export default Header;
  • src\App.tsx

    import Layout from "./components/layout/Layout";
    import Home from "./pages/Home";
    import { BookStoreThemeProvider } from "./context/themeContext";
    import { RouterProvider, createBrowserRouter } from "react-router-dom";
    import Error from "./components/common/Error";
    
    const router = createBrowserRouter([
      {
        path: "/",
        element: <Layout><Home /></Layout>,
        errorElement: <Error />
      },
      {
        path: "/books",
        element: <Layout><div>도서 목록</div></Layout>
      },
    ]);
    
    function App() {
      return (
        <BookStoreThemeProvider>
            <RouterProvider router={router} />
        </BookStoreThemeProvider>
      );
    }
    
    export default App;
    
  • src\components\common\Error.tsx

    • Error 정보가 자동으로 넘어옴 → useRouteError로 사용 가능

      import { useRouteError } from "react-router-dom";
      
      interface RouteError {
          statusText?: string;
          message?: string;
      }
      
      function Error() {
          const error = useRouteError() as RouteError;
          return (
              <div>
                  <h1>오류가 발생했습니다.</h1>
                  <p>다음과 같은 오류가 발생했습니다.</p>
                  <p>{error.statusText || error.message}</p>
              </div>
          );
      }
      
      export default Error;

뭔가 클론 코딩하는 느낌이다.☹️ 아직 아는 게 많지가 않아서 그런가..

profile
이것저것 관심 많은 개발자👩‍💻

0개의 댓글