반응형

React Redux Toolkit Posts로 연습하기

 

./api/interface.ts

export interface PostsListProps {
    posts: [
        {
            id: string,
            title: string,
            content: string
        }
    ]
}

 

./app/store.ts

counter내용은 이전에 만든 내용입니다.

// 툴킷에서 구성 저장소를 가져옵니다.
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "../features/count/counterSlice";
import postsReducer from "../features/posts/postsSlice";

export const store = configureStore({
    reducer: {
        counter: counterReducer,
        posts: postsReducer
    }
});

 

./features/posts/postsSlice.ts

import { createSlice } from "@reduxjs/toolkit";
import { PostsListProps } from "../../api/interface";

const initialState = [
    {
        id: "1",
        title: "제목입니다.",
        content: "Hello World 내용입니다~",
    },
    {
        id: "2",
        title: "Slice TEST !",
        content: "Slice TEST ing~~",
    },
];

const postsSlice = createSlice({
    name: "posts",
    initialState,
    reducers: {
        postAdded(state, action) {
            state.push(action.payload);
        }
    }
});

export const seletAllPosts = (state: PostsListProps) => state.posts;

export const { postAdded } = postsSlice.actions;

export default postsSlice.reducer;

 

./features/posts/PostsList.tsx

import styles from "./PostsList.module.scss";

import { useSelector } from "react-redux";
// import { PostsListProps } from "../../api/interface";
import { seletAllPosts } from "./postsSlice";
import { AddPostForm } from "./AddPostForm";

export const PostsList = () => {

    // 아래처럼 사용도 가능합니다.
    // const posts = useSelector((state: PostsListProps) => state.posts);
    // postsSlice.ts에서 내용을 처리해준 방법입니다.
    const posts = useSelector(seletAllPosts);

    const renderedPosts = posts.map(post => (
        <section key={post.id} className={styles.posts_wrap}>
            <h3>{post.title}</h3>
            <p>{post.content.substring(0, 100)}</p>
        </section>
    ))
    return (
        <article className={styles.posts_list_wrap}>
            <h2>Posts</h2>
            <AddPostForm />
            <div className={styles.posts_list}>
                {renderedPosts}
            </div>
        </article>
    );
};

 

./features/posts/AddPostForm.tsx

import { ChangeEvent, useState } from "react";
import { useDispatch } from "react-redux";
// nanoid = 임의의 ID를 생성합니다. uuid같은 느낌입니다.
import { nanoid } from "@reduxjs/toolkit";

import { postAdded } from "./postsSlice";

export const AddPostForm = () => {

    const dispatch = useDispatch();

    const [title, setTitle] = useState<string>("");
    const [content, setContent] = useState<string>("");

    const onTitleChanged = (e: ChangeEvent<HTMLInputElement>) => setTitle(e.target.value);
    const onContentChanged = (e: ChangeEvent<HTMLTextAreaElement>) => setContent(e.target.value);

    const onSavePostClicked = () => {
        if (title.length > 1 && content.length > 5) {
            dispatch(postAdded({
                id: nanoid(),
                title,
                content
            }))

            setTitle("");
            setContent("");
        }
    }

    return (
        <section>
            <h2>새로운 포스트 생성하기</h2>
            <form>
                <label htmlFor="postTitle">Post Title: </label>
                <input type="text" id="postTitle" name="postTitle" value={title} onChange={onTitleChanged} />

                <label htmlFor="postContent">Content: </label>
                <textarea id="postContent" name="postContent" value={content} onChange={onContentChanged} />

                <button type="button" onClick={onSavePostClicked}>Save Post</button>
            </form>
        </section>
    );
};

 

./App.tsx

import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import { Counter } from './features/count/Counter';
import { PostsList } from './features/posts/PostsList';
import { Main } from './pages/Main';

function App() {
  return (
    <Router>
      <Routes>
        <Route path="/" element={<Main />} />
        <Route path="/count" element={<Counter />} />
        <Route path="/posts-list" element={<PostsList />} />
      </Routes>
    </Router>
  );
}

export default App;

 

./index.tsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';

// redux
import { store } from './app/store';
import { Provider } from 'react-redux';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <Provider store={store}>
    <App />
  </Provider>
);

 

 


 

PostsList.tsx에서 해야 하는 일들을 postsSlice.ts로 옮긴 버전입니다.

 

./features/posts/postsSlice.ts

import { createSlice, nanoid, PayloadAction } from "@reduxjs/toolkit";
import { PostsListProps, PostsProps } from "../../api/interface";

const initialState = [
    {
        id: "1",
        title: "제목입니다.",
        content: "Hello World 내용입니다~",
    },
    {
        id: "2",
        title: "Slice TEST !",
        content: "Slice TEST ing~~",
    },
];

const postsSlice = createSlice({
    name: "posts",
    initialState,
    reducers: {
        postAdded: {
            reducer(state, action: PayloadAction<PostsProps>) {
                state.push(action.payload);
            },
            prepare(title: string, content: string) {
                return {
                    payload: {
                        id: nanoid(),
                        title,
                        content
                    }
                }
            }
        }
    }
});

export const seletAllPosts = (state: PostsListProps) => state.posts;

export const { postAdded } = postsSlice.actions;

export default postsSlice.reducer;

reducer(state, action: PayloadAction<PostsProps>), prepare(title: string, content: string) 이 두 부분에서 조금 막혔었습니다.

prepare의 반환 값과 action의은 동일한 유형을 가져야 하는데, reducer에 대한 입력 값은 추론할 수 없기 때문에 수동으로 지정해야 했었는데 자꾸 이상한 방향으로 갔던 것 같습니다..ㅠ

 

./features/posts/AddPostForm.tsx

import { ChangeEvent, useState } from "react";
import { useDispatch } from "react-redux";
// nanoid = 임의의 ID를 생성합니다. uuid같은 느낌입니다.
// import { nanoid } from "@reduxjs/toolkit";

import { postAdded } from "./postsSlice";

export const AddPostForm = () => {

    const dispatch = useDispatch();

    const [title, setTitle] = useState<string>("");
    const [content, setContent] = useState<string>("");

    const onTitleChanged = (e: ChangeEvent<HTMLInputElement>) => setTitle(e.target.value);
    const onContentChanged = (e: ChangeEvent<HTMLTextAreaElement>) => setContent(e.target.value);

    const onSavePostClicked = () => {
        if (title.length > 1 && content.length > 5) {
            dispatch(postAdded(title, content))

            setTitle("");
            setContent("");
        }
    }

    return (
        <section>
            <h2>새로운 포스트 생성하기</h2>
            <form>
                <label htmlFor="postTitle">Post Title: </label>
                <input type="text" id="postTitle" name="postTitle" value={title} onChange={onTitleChanged} />

                <label htmlFor="postContent">Content: </label>
                <textarea id="postContent" name="postContent" value={content} onChange={onContentChanged} />

                <button type="button" onClick={onSavePostClicked}>Save Post</button>
            </form>
        </section>
    );
};

dispatch() 부분이 깔끔하게 변한것을 확인할 수 있습니다.

반응형
반응형

React Redux Toolkit Counter로 연습하기

npm

$ npm install @types/react-router-dom
$ npm install react-router-dom
$ npm install @reduxjs/toolkit react-redux
$ npm install node-sass

 

./api/interface.ts

export interface CounterProps {
    counter: {
        count: number
    }
}

 

./app/store.ts

// 툴킷에서 구성 저장소를 가져옵니다.
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "../features/count/counterSlice";

export const store = configureStore({
    reducer: {
        counter: counterReducer
    }
});

 

./features/count/counterSlice.ts

import { createSlice } from "@reduxjs/toolkit";

const initialState = {
    count: 0
}

export const counterSlice = createSlice({
    name: "counter",
    initialState,
    reducers: {
        increment: (state) => {
            state.count += 1;
        },
        decrement: (state) => {
            state.count -= 1;
        },
        reset: (state) => {
            state.count = 0;
        },
        incrementByAmount: (state, action) => {
            state.count += action.payload;
        }
    }
});

export const { increment, decrement, reset, incrementByAmount } = counterSlice.actions;

export default counterSlice.reducer;

 

./features/count/Counter.tsx

import styles from "./Counter.module.scss";

import { useSelector, useDispatch } from "react-redux";
import { CounterProps } from "../../api/interface";
import { increment, decrement, reset, incrementByAmount } from "./counterSlice";
import { ChangeEvent, useState } from "react";

export const Counter = () => {
    
    const count = useSelector((state: CounterProps) => state.counter.count);
    const dispatch = useDispatch();

    const [incrementAmount, setIncrementAmount] = useState<number>(0);

    const addValue = Number(incrementAmount);

    const resetAll = () => {
        setIncrementAmount(0);
        dispatch(reset());
    }

    return (
        <section className={styles.counter_wrap}>
            <div className={styles.count_wrap}>{count}</div>

            <div className={styles.count_button_wrap}>
                <button onClick={() => dispatch(increment())}>+</button>
                <button onClick={() => dispatch(decrement())}>-</button>
            </div>

            <input type="number" value={incrementAmount} onChange={(e: ChangeEvent<HTMLInputElement>) => setIncrementAmount(Number(e.target.value))} />

            <div className={styles.count_button_wrap}>
                <button onClick={() => dispatch(incrementByAmount(addValue))}>ADD Amount</button>
                <button onClick={resetAll}>Reset</button>
            </div>
        </section>
    );
}

 

./App.tsx

import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import { Counter } from './features/count/Counter';
import { Main } from './pages/Main';

function App() {
  return (
    <Router>
      <Routes>
        <Route path="/" element={<Main />} />
        <Route path="/count" element={<Counter />} />
      </Routes>
    </Router>
  );
}

export default App;

 

./index.tsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';

// redux
import { store } from './app/store';
import { Provider } from 'react-redux';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <Provider store={store}>
    <App />
  </Provider>
);

 

반응형
반응형

vercel에 간단하게 만든 사이트 빌드하기 입니다.

이전 작성했던 gh-pages를 이용해서 웹 페이지 호스팅하기와 비슷한 내용입니다.

gh-pages를 이용한 웹 페이지 호스팅하기 바로가기

Vercel 바로가기

 

1. github에 ReactJS or NextJS로 작성된 파일을 올렸다고 가정한 이후에 시작합니다.

 

2. vercel site로 이동합니다.

 

3. 아이디를 생성하고, 깃허브를 연동시켜줍니다.

 

4. dashboard에서 Add New...를 클릭하고 Project로 이동합니다.

 

4. 자신이 생성한 NextJS파일을 Import해줍니다.

 

5. Environment Variables에 env에 적어준 내용을 적어준다고 생각하면 편합니다. 이후 Deploy를 눌러주면 끝납니다.

( env파일을 github에 직접 올리지 않기 때문에 여기에 env내용을 적어준다고 생각하시면 됩니다. )

 

6. 폭죽이 터지면서 성공했다는 내용이 나옵니다. 만약 실패하면 실패한 내용들이 아래 주루룩 나오게 됩니다.

 

이후 업데이트를 하려면 github에 내용을 업데이트 해주면 자동으로 vercel에서도 업데이트를 해줍니다.

반응형
반응형

getStaticProps VS getServerSideProps

 

getStaticProps ( SSG: Static Site Generation )

getStaticProps는 최초 빌드 시에 딱 한번만 호출이 됩니다. 즉, 최초 빌드 시 빌드되는 값이 추후에 수정되는 일이 없는 경우에 사용하기 좋습니다. 

장점은 호출 시 마다 매번 fetch를 하지 않기 때문에 성능면에서는 getServerSideProps보다 좋습니다.

 

export async function getStaticProps() {

    return {
        props: {}
    }
}

 

getServerSideProps ( SSR: Server Side Rendering )

getServerSideProps는 getStaticProps와 다르게 요청이 들어올 때마다 호출되며, 그 때마다 사전 렌더링을 진행합니다.

이 경우, 요청 시마다 다시 호출하기 때문에 빌드 이후 자주 바뀌게 될 동적 데이터가 들어갈 때 사용하기 좋습니다.

 

export async function getServerSideProps() {

    return {
        props: {}
    }
}

getServerSideProps는 서버와 관련된 기능입니다.

getServerSideProps()안에 들어가는 코드는 어떤 코드를 쓰던지 서버에서 작동합니다.

이걸 이용해서 API Key를 숨기는것도 가능합니다. ( BackEnd에서 실행되기 때문입니다. )

 

호출은 _app.js의 component를 호출하고 pageProps에서 호출된다고 생각하면 됩니다.

 

Only absolute URL

getServerSideProps는 서버에서 작동하기 때문에 프론트엔드에서 실행할 때와 다르게 URL이 없기 때문에 fetch를 사용할 때 절대주소를 입력해주어야 합니다.

export async function getServerSideProps() {

    // TypeError: Only absolute URLs are supported
    const { results } = await ( await fetch(`http://localhost:3000/~`)).json();

    return {
        // props key 내부에는 원하는 데이터를 아무거나 넣을 수 있다. ( 무엇을 return하던지 props로써 page에게 주게된다. ex) Home())
        // 이 데이터는 pageProps를 통해 전달된다.
        props: {}
    }
}

 

반응형
반응형

next.config.js 란?

NextJS에서 커스텀 설정을 하기 위해 프로젝트 디렉터리의 루트폴더에 next.config.js or next.config.mjs 파일을 만들 수 있습니다.

이는 JSON파일이 아닌 NodeJS 모듈입니다.

NextJS 서버 및 빌드 단계에서 사용되며, 브라우저 빌드에는 포함되지 않습니다.

 

몇가지 기능들

 

Base Path

// next.config.js
module.exports = {
    basePath: "/docs"
}

// app.js
<Link href="/about">About으로 이동</Link>
<Image src="/docs/new.png" alt="이미지" />

 

위처럼 basePath를 적용시키면 라우터 이동시에 /about이 아닌 /docs/about으로 이동하게 됩니다.

Image는 위처럼 /docs를 추가해서 해주어야 이미지가 제대로 제공됩니다.

 

 

Rewrites

async rewrites() {
    return [
        {
            source: "/about",
            destination: "/"
        }
    ]
}

rewrites는 클라이언트 측 라우팅에 <Link href="/about">~</Link> 적용되며, 위처럼 재작성이 적용됩니다.

간단하게 말하면 source URL로 이동을 하면 destination이 실행됩니다.

예를 들어 긴 URL을 감추거나 API키를 감추거나 하는데 사용할 수 있습니다.

 

Redirects

module.exports = {
  async redirects() {
    return [
      {
        source: '/about',
        destination: '/',
        permanent: true,
      },
    ]
  },
}

redirects를 사용하면 들어오는 요청 경로를 다른 대상 경로로 리디렉션할 수 있습니다.

permanent는 boolean으로 설정할 수 있으며, true면 클라이언트 / 검색 엔진이 리디렉션을 영구적으로 캐싱하도록 지시하는 308 상태 코드를 사용하고, false면 일시적이며 캐시되지 않는 307 상태 코드를 사용합니다.

 

redirects은 source URL이 destination URL로 변경되는 것을 유저가 확인할 수 있지만, rewrites는 source URL만 유저가 확인할 수 있고, destination URL은 유저가 알 수 없습니다.

 

images

const nextConfig = {
  reactStrictMode: true,
  images: {
    domains: [
      "www.notion.so"
    ]
  }
}

remotePatterns와 맟찬가지로 도메인 구성을 사용하여 외부 이미지에 허용된 호스트 이름 목록을 제공할 수 있습니다.

간단하게 말하면 외부에서 이미지를 가져올 때, 외부 URL을 함부로 사용할 수 없기 때문에 next.config.js에서 사용할 수 있도록 도와준다고 생각하면 됩니다.

반응형
반응형

NextJS 시작하기 

NextJS를 시작하기 전에 ReactJS와 NextJS의 차이점을 알고 넘어가면 좋습니다.

 

Framework VS Library

ReactJS: 라이브러리입니다.

NextJS:  ReactJS의 프레임워크입니다.

 

  • Framework
    원하는 기능 구현에 집중하여 개발할 수 있도록 일정한 형태와 필요한 기능들을 갖추고 있는 뼈대를 의미합니다.
    애플리케이션 개발 시 필수적인 코드, 알고리즘, DB연동과 같은 기능들을 위해 어느 정도 뼈대를 제공하며 이러한 뼈대 위에서 사용자는 코드를 작성하여 애플리케이션을 개발합니다. 앱 / 서버등의 구동, 메모리 관리, 이벤트 루프 등의 공통된 부분으 프레임워크가 관리해주며, 사용자는 플레임워크가 정해준 방식대로 구현하면 됩니다.

  • Library
    소프트웨어를 개발할 때 컴퓨터 프로그램이 사용하는 비휘발성 자원들의 모임입니다. 즉 특정 기능을 모아둔 코드, 함수들의 집합이며 코드 작성 시 활용 가능한 도구들을 의미합니다.

CSR VS SSR

ReactJS: create-react-app로 만든 ReactJS는 CSR( Client Side Rendering )입니다.

NextJS: create-next-app로 만든 NextJS는 SSR( Serveer Side Rendering )입니다.

 

위 둘의 차이점은 유저가 브라우저에서 보는 화면인 UI를 어디서 만들어 주느냐에 따라 구분됩니다.

CSR은 클라이언트에서, SSR은 서버에서 화면을 구성합니다.

 

CSR의 동작 방식

유저, 서버, 브라우저 각각의 입장을 나누어 이해합니다. ( 브라우저 = 유저와 앱의 연결로 )

  1. 유저가 브라우저를 통해 앱에 접속합니다.
  2. 앱은 브라우저에게 Javascript의 정보가 들어있는 빈 HTML문서를 전달합니다. ( 브라우저에게 Javascript파일을 전달합니다. )
  3. 브라우저는 Javascript파일을 다운로드하고 동시에 유저는 빈 화면을 보게됩니다. ( 접속에 대한 응답입니다. )
  4. 브라우저에서 Javascript파일의 다운로드가 끝나면 React Code가 있는 Javascript파일을 실행합니다.
  5. 브라우저에 있는 React Code가 UI를 렌더링하니다. ( 동적 렌더링 )
  6. 유저는 앱이 보여주고자 했던 화면을 보게 됩니다.

즉, 브라우저가 Javascript코드를 갖고있지 않거나, 요청중인 상태라면 UI를 구성할 수 없으며, 유저는 빈화면을 보게됩니다.

리액트가 실행되기 전까지 유저 화면에 아무것도 보이지 않는 것입니다. 이렇게 클라이언트 측에서 UI를 빌드하는 것을 CSR방식이라고 합니다.

 

CSR의 장점

  • 초기 로드에 성공하면 이후 렌더링이 빠릅니다.
  • 클라이언트에서 처리하기 때문에 서버에 요청할 것이 적습니다. ( 서버의 부담이 적습니다. )

CSR의 단점

  • SEO( Search Engine Optimization )에 좋지 않습니다. ( 초기 HTML파일이 비어있기 때문에 데이터 수집에 어려움이 있습니다. 검색엔진.. )
  • 초기 로드가 오래 걸립니다.
  • 외부 라이브러리에 의존할 경우가 많습니다.

 

SSR 동작 방식

유저, 서버, 브라우저 각각의 입장을 나누어 이해합니다. ( 브라우저 = 유저와 앱의 연결로 )

  1. 유저가 브라우저를 통해 앱에 접속합니다.
  2. 서버에서 React를 실행시켜줍니다. ( React는 UI를 렌더링합니다. ) 
  3. 렌더링된 결과를 통해 브라우저에게 HTML을 제공합니다. ( 이 때, 유저는 앱의 초기화면을 보게됩니다. [ 접속에 대한 응답 ] )
  4. 이후, 브라우저는 React Code가 있는 Javascript파일을 다운받고 실행시킵니다. ( 이때부터 일반적인 React앱과 같이 CSR의 동작을 하게되며 이 과정을 Hydration이라고합니다. )
    Hydration: React Code가 브라우저에 이미 존재하는 HTML과 동기화하여 앱이 동적으로 상호작용할 수 있도록하는 과정입니다.
    즉, 서버에서 모든 UI를 구성한 후, 유저에게 응답해 화면을 보여주는 방식으로, 화면이 Pre-Rendering되어 유저는 인터넷 속도에 상관없이 화면에 무언가 나오는 것을 볼 수 있습니다. 이렇게 서버 측에서 UI를 렌더링하는 것을 SSR 동작 방식이라고 합니다.

 

SSR의 장점

  • SEO ( Search Engine Optimization )에 좋습니다. ( HTML파일에 모든 정보가 포함되기 때문입니다. )
  • 초기 로딩이 빠릅니다.
  • 클라이언트의 부담이 CSR의 비해 적습니다.

SSR의 단점

  • 서버에서 전체 앱을 미리 렌더링하기 때문에 컴포넌트 로딩이 오래 걸리면 유저는 빈 화면을 보게됩니다.
  • 모든 요청에 대해 필요한 부분을 수정하는 것이 아닌 새로운 HTML페이지를 내려주기 때문에 속도 저하나 새로고침이 발생합니다.
  • 페이지를 요청할 때마다 새로고침되어서 UX가 다소 떨어집니다.

 

NextJS의 특징

코드 스플리팅

일반적인 리액트 싱글페이지에서는 초기 렌더링때 모든 컴포넌트를 내려받습니다. 하지만 규모가 커지고, 용량이 커지면 로딩속도가 지연될 수 있는 문제점이 있습니다.

NextJS는 이러한 문제점을 개선해서 필요에 따라 파일을 불러올 수 있도록 여러 개의 파일을 분리하는 코드 스플리팅을 사용하였습니다. 폴더구조에 pages폴더 안에 라우트들이 들어가며, Components폴더에는 React Component들이 들어가게 됩니다.

ex ) 브라우저가 실행되고, 사용자가 접속을 하면 첫 페이지는 index page만 불러오게 되고, 그 이후에 다른 페이지로 넘어갔을 때는 해당 페이지만 불러오게 됩니다.

 

파일기반 내비게이션 기능

ReactJS에서는 루트를 위해서 react-router-dom이라는 라이브러리를 사용하여 라우팅 설정을 해주어야 합니다.

그로 인해 페이지의 경로에 대해 직접 설정을 해주어야 하지만, NextJS는 파일 시스템 기반 라우팅을 사용합니다. 폴더의 경로에 따라 페이지의 경로가 설정되어 구축이 빠르고 관리가 편리한 장점이 있습니다.

 

Hot Code Reloading을 지원하는 개발 환경

NextJS 개발 환경에서는 코드 변경 사항이 저장되면 응용 프로그램을 자동으로 다시 로드합니다. 개발 모드일 때 소스코드를 저장하면 브라우저 오른쪽 하단에 애니메이션 아이콘이 생기며, 이는 NextJS가 응용 프로그램을 컴파일하고 있다는 것을 의미합니다

 

styled-jsx 지원

NextJS는 자체 CSS-In-JS인 styled-jsx를 지원합니다. 기본적으로 제공하는 기능이기 때문에 스타일을 서버 사이드 렌더링 하기 위한 설정이 필요하지 않습니다.

 

반응형
반응형

저장없이 배열에 내용을 추가, 읽기, 수정, 삭제하는 내용입니다.

버튼을 조각조각 내서 재사용성을 늘렸습니다.

 

내부 트리

├─ src
│  ├─ App.css
│  ├─ App.tsx
│  ├─ ProtectRouter.tsx
│  ├─ components
│  │  ├─ crud
│  │  │  └─ CRUD.tsx
│  │  ├─ item
│  │  │  ├─ CreateButton.tsx
│  │  │  ├─ TextInput.tsx
│  │  │  ├─ Textarea.tsx
│  │  │  └─ UpdateButton.tsx
│  │  └─ ts
│  │     └─ interface.ts
│  ├─ index.tsx
│  ├─ react-app-env.d.ts
│  └─ styles
│     └─ CRUD.module.scss
└─ tsconfig.json

 

ts

./ts/interface.ts

import { Dispatch, SetStateAction } from "react";

export interface TextProps {
    text: string,
    setText: Dispatch<SetStateAction<string>>
}

export interface ButtonProps {
    btn: boolean,
    name: string,
    setBtn: Dispatch<SetStateAction<boolean>>
}

export interface ArrayProps {
    id: number,
    title: string,
    contents: string,
}

 

item

./item/CreateButton.tsx

import React from "react";
import { ButtonProps } from "../ts/interface";

export const CreateButton: React.FunctionComponent<ButtonProps> = ({ name, btn, setBtn}) => {

    const onClick = () => {
        setBtn(true);
    }

    return (
        <>
            <button onClick={onClick}>{name}</button>
        </>
    )
};

 

./item/Textarea.tsx

import React from "react";
import { TextProps } from "../ts/interface";

export const Textarea: React.FunctionComponent<TextProps> = ({ text, setText }) => {

    const onChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
        const { target: { value } } = e;

        setText(value);
    }

    return (
        <>
            <textarea value={text} onChange={onChange} placeholder="내용을 입력해주세요." />
        </>
    );
};

 

./item/TextInput.tsx

import React from "react";
import { TextProps } from "../ts/interface";

export const TextInput: React.FunctionComponent<TextProps> = ({ text, setText }) => {

    const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        const { target: { value } } = e;

        setText(value);
    }

    return (
        <>
            <input type="text" value={text} onChange={onChange} placeholder="제목을 입력해주세요." />
        </>
    );
};

 

./item/UpdateButton.tsx

import React from "react";
import { ButtonProps } from "../ts/interface";

export const UpdateButton: React.FunctionComponent<ButtonProps> = ({ name, btn, setBtn}) => {

    const onClick = () => {
        setBtn(true);
    }

    return (
        <>
            <button onClick={onClick}>{name}</button>
        </>
    )
};

 

crud

./crud/CRUD.tsx

import { useCallback, useEffect, useState } from "react";
import styles from "../../styles/CRUD.module.scss";
import { CreateButton } from "../item/CreateButton";
import { Textarea } from "../item/Textarea";
import { TextInput } from "../item/TextInput";
import { UpdateButton } from "../item/UpdateButton";
import { ArrayProps } from "../ts/interface";

export const CRUD = () => {
    const [title, setTitle] = useState<string>("");
    const [contents, setContents] = useState<string>("");
    // false: createBtn | true: updateBtn
    const [btnChange, setBtnChange] = useState<boolean>(false);
    const [createBtn, setCreateBtn] = useState<boolean>(false);
    const [updateBtn, setUpdateBtn] = useState<boolean>(false);
    const [array, setArray] = useState<ArrayProps[]>([]);
    const [changeCount, setChangeCount] = useState<number>(0);
    // 계속 증가하는 id Number
    const [count, setCount] = useState<number>(0);
    
    // 글 생성
    const list = useCallback(() => {
        setArray(prev => [...prev, {id: count, title: title, contents: contents}]);
        setCount(prev => prev + 1);
        setTitle("");
        setContents("");
        setCreateBtn(false);
    }, [title, contents, count]);
    useEffect(() => {
        if (createBtn) list();
    }, [createBtn, list]);

    // 글 업데이트
    useEffect(() => {
        if (updateBtn) {
            let update = [...array];

            update[changeCount].title = title;
            update[changeCount].contents = contents;

            setArray(update);
            setTitle("");
            setContents("");
            setBtnChange(false);
            setUpdateBtn(false);
        }
    }, [updateBtn, array, title, contents, changeCount])

    // 삭제 | 업데이트
    const onUpdate = (list: string, item: ArrayProps) => {

        if (list === "delete") setArray(array.filter(arr => arr.id !== item.id));
        else if (list === "update") {
            setChangeCount(item.id);
            setTitle(item.title);
            setContents(item.contents);
            setBtnChange(true);
        }
    }

    return (
        <article className={styles.crud_wrap}>
            <div className={styles.text_wrap}>
                <TextInput text={title} setText={setTitle} />
            </div>
            <div className={styles.contents_wrap}>
                <Textarea text={contents} setText={setContents} />
            </div>
            <div className={styles.button_wrap}>
                {
                    btnChange ? <UpdateButton name="업데이트" btn={updateBtn} setBtn={setUpdateBtn} /> : <CreateButton name="확인" btn={createBtn} setBtn={setCreateBtn} />
                }
            </div>
            <ul>
                {
                    array.length > 0 && array.map(item => (
                        <li key={item.id}>
                            <h2>{item.title}
                                <div className={styles.arr_wrap}>
                                    <span onClick={() => onUpdate("update", item)}>업데이트 | </span>
                                    <span onClick={() => onUpdate("delete", item)}>삭제</span>
                                </div>
                            </h2>
                            <p>{item.contents}</p>
                        </li>
                    ))
                }
            </ul>
        </article>
    );
}

 

App.tsx

import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';

// Components
import { CRUD } from './components/crud/CRUD';

function App() {
  return (
    <Router>
      <Routes>
        <Route path="/crud" element={<CRUD />} />
      </Routes>
    </Router>
  );
}

export default App;

 

styles

./styles/CRUD.module.scss

* {
    box-sizing: border-box;
}
.crud_wrap {
    width: 100%;
    max-width: 500px;
    margin: 0 auto;
    border: 2px solid #DDD;
    border-radius: 16px;
    padding: 2rem;
    box-sizing: border-box;
    > div {
        margin-bottom: 1rem;
    }
    .text_wrap {
        > input { width: 100%; padding: .5rem; border: 1px solid #DDD; border-radius: 16px; }
    }
    .contents_wrap {
        > textarea { width: 100%; padding: .5rem; border: 1px solid #DDD; border-radius: 16px; resize: none; }
    }
    .button_wrap {
        margin-top: 2rem;
        > button { width: 100%; padding: .5rem; background: #DDD; border-radius: 16px; border: 0; color: #FFF; font-size: 16px; font-weight: 700; }
    }
    > ul {
        list-style: none;
        padding: 0;
        > li {
            > h2 {
                position: relative;
                font-size: 1.2rem;
                .arr_wrap {
                    position: absolute;
                    top: 0;
                    right: 0;
                    > span {
                        font-size: .8rem;
                        font-weight: 500;
                        cursor: pointer;
                    }
                }
            }
            > p { font-size: 1rem; }
        }
    }
}
반응형
반응형

npm

npm install react-router-dom

 

App.tsx

import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';

// Guard
import { ProtectRouter } from './ProtectRouter';

// Components
import { Login } from './components/login/Login';
import { Main } from './components/main/Main';

function App() {
  return (
    <Router>
      <Routes>
        <Route element={<ProtectRouter />}>
          <Route path="/" element={<Main />} />
        </Route>

        <Route path="/login" element={<Login />} />
      </Routes>
    </Router>
  );
}

export default App;

 

Main 컴포넌트를 함부로 들어올 수 없도록 ProtectRouter로 감싸줍니다.

 

components/login/Login.tsx

import React, { useState } from "react";
import { useNavigate } from "react-router-dom";

export const Login = () => {

    const navigation = useNavigate();
    const [login, setLogin] = useState<string>("");

    const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        const { target: { value } } = e;

        setLogin(value);
    }

    const onClick = () => {
        localStorage.setItem("login", login);
        navigation("/");
    }

    return (
        <article>
            <input type="text" value={login} onChange={onChange} />
            <button onClick={onClick}>로그인 성공</button>
        </article>
    );
}

로컬스토리지에 로그인 정보를 넣어줍니다.

 

ProtectRouter.tsx

import { Navigate, Outlet } from "react-router-dom";

export const useAuth = () => {

    const auth = localStorage.getItem("login");
    let loggedIn: boolean;

    if (auth) loggedIn = true;
    else loggedIn = false;

    return loggedIn;
}

export const ProtectRouter = () => {
    const isAuth = useAuth();

    return isAuth ? <Outlet /> : <Navigate to="/login" />
}

로그인정보에서 넣어준 정보가 있다면 <Outlet />으로 없다면 <Navigate to="/login" />으로 보내줍니다.

만약 isAuth가 false인 경우에는 /login으로 강제 이동 시켜줍니다.

:: <Outlet />: 하위 경로 요소를 렌더링 할 때, 상위 경로 요소에서 <Outlet />을 사용해줍니다.

 

components/main/Main.tsx

import { useNavigate } from "react-router-dom";

export const Main = () => {

    const navigation = useNavigate();

    const onClick = () => {
        localStorage.removeItem("login");
        navigation("/login");
    }
    return (
        <article>
            <button onClick={onClick}>로그아웃</button>
        </article>
    );
}

로그아웃을 클릭하면 로컬스토리지를 비우고 로그인으로 이동시켜줍니다.

반응형
반응형

Redux와 Recoil

Redux와 Recoil은 React를 위한 상태관리( State Management )라이브러리입니다.

Redux는 단방향 데이터 흐름으로 만들어진 Flux 아키텍처를 기반으로 하고, Recoil은 요소들을 작은 상태 단위로 관리하고 이 요소들을 결합하여 구성하는 Atomic Design Pattern을 기반으로 합니다.

 

Recoil ( Recoil 바로가기 )

Recoil은 Facebook에서 발표한 React 전용 라이브러리입니다.

Recoil은 규모가 있는 어플리케이션에서 보다 일관된 데이터 관리를 위해 고안되었습니다.

 

Recoil의 특징

  • React 문법에 친화적입니다. 전역 상태 값도 React의 State처럼 간단한 GET( 게터 ) / SET( 세터 ) 인터페이스로 사용할 수 있는 Boilerpate-free API를 제공합니다.
  • React와 개발 방향성이 같습니다. Recoil은 선언적인 API로 React의 코드를 단순하게 바꿔줍니다.
  • 비동기 처리가 단순합니다. Redux처럼 추가 라이브러리 없이 처리가 가능합니다.
  • 강력한 캐싱기능이 있습니다. 캐싱기능으로 비동기 데이터 처리를 빠르게 해줍니다.
  • 안정성에 대한 걱정이 있습니다. facebookexperimental/Recoil 실험적인 상태 관리 프레임워크라는 점입니다.
  • 생태계가 Redux에 비해서 훨씬 적습니다.

 

Recoil 사용해보기

 

npm

$ npm install recoil
$ npm install react-router-dom
$ npm install recoil-persist
$ npm install node-sass

 

ts/interface.ts

export interface ArrayStateProps {
    id: number,
    text: string,
    isComplete: boolean
};

 

ts/recoil.ts

import { RecoilRoot, atom, selector, useRecoilState, useRecoilValue } from "recoil";
import { recoilPersist } from 'recoil-persist'

const { persistAtom } = recoilPersist()

// setTextState || TextState
export const setTextState = atom({
    key: "setTextState",
    default: ""
});
export const TextState = selector({
    key: "TextState",
    get: ({ get }) => {
        const recoilText = get(setTextState);

        return recoilText;
    }
});

// setArrayState || ArrayState
export const setArrayState = atom({
    key: "setArrayState",
    default: [],
    effects_UNSTABLE: [persistAtom]
});
export const ArrayState = selector({
    key: "ArrayState",
    get: ({ get }) => {
        const recoilArray = get(setArrayState);

        return recoilArray;
    }
})

export { RecoilRoot, atom, selector, useRecoilState, useRecoilValue };

RecoilRoot

Recoil State를 사용하는 Component는 부모 트리 어딘가에 나타나는 RecoilRoot가 필요합니다. App Component가 RecoilRoot를 넣기 가장 좋은 Component입니다.

 

Atom

atom은 상태( State )의 일부를 나타냅니다. atom은 어떤 Component에서나 읽고 쓸 수 있습니다. atom의 값을 읽는 Component들은 암묵적으로 atom을 구독합니다. 그래서 atom에 어떤 변화가 생기면 그 atom을 구독하고 있는 모든 Component들이 재 렌더링 되는 결과가 발생합니다.

 

useRecoilState

Component가 atom을 읽고 쓰게 하기 위해서는 useRecoilState()를 사용해야 합니다. [ 사용법은 useState()와 비슷합니다. ]

 

Selector

selector는 파생된 상태( Derived State )의 일부를 나타냅니다. 파생된 상태는 상태의 변화입니다. 파생된 상태를 어떤 방법으로 주어진 상태를 수정하는 순수 함수에 전달된 상태의 결과물로 생각 할 수 있습니다.

 

useRecoilValue

useRecoilValue()을 사용해서 결과물의 값을 읽을 수 있습니다.

 

persistAtom

persistAtom을 사용해서 localStorage에 저장 할 수있습니다.

 

 

App.tsx

import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import { RecoilRoot } from './ts/recoil';

// Components
import { Main } from './components/main/Main';

function App() {
  return (
    <RecoilRoot>
      <Router>
        <Routes>
          <Route path="/" element={<Main />} />
        </Routes>
      </Router>
    </RecoilRoot>
  );
}

export default App;

 

components/main/Main.tsx

import { MainContents } from "./MainContents";

export const Main = () => {
    return (
        <article>
            <MainContents />
        </article>
    );
};

 

components/main/MainContents.tsx

import style from "../../styles/main.module.scss";
import { ArrayStateProps } from "../../ts/interface";
import { TextState, useRecoilValue, ArrayState } from "../../ts/recoil";
import { Button } from "../item/Button";
import { ClearButton } from "../item/ClearButton";
import { TextInput } from "../item/TextInput";

export const MainContents = () => {
    const text = useRecoilValue(TextState);
    const array = useRecoilValue(ArrayState);

    return (
        <section className={style.main_contents_wrap}>
            <div>
                <TextInput />
                <Button />
            </div>
            <div>
                Input 내용: {text}
            </div>
            <div className={style.array_wrap}>
                {
                    array.map((item: ArrayStateProps, index: number) => (
                        <div key={index}><span>{item.id}:</span> {item.text} <ClearButton clear={item.id} /></div>
                    ))
                }
            </div>
        </section>
    );
};

 

components/item/Button.tsx

import { ArrayStateProps } from "../../ts/interface";
import { setArrayState, useRecoilState, setTextState } from "../../ts/recoil";

export const Button = () => {

    const [text, setText] = useRecoilState(setTextState);
    const [array, setArray] = useRecoilState(setArrayState);

    const onClick = () => {
        let num: number = 0;
        if (array.at(-1)?.id !== undefined) num = array.at(-1).id + 1

        setArray((array: ArrayStateProps[]) => [
            ...array,
            {
                id: num,
                text: text,
                isComplete: false
            }
        ]);
        setText("");
    }

    const onDelete = () => {
        setArray([]);
    }

    return (
        <>
            <button onClick={onClick}>저장하기</button>
            <button onClick={onDelete}>삭제하기</button>
        </>
    );
}

 

components/item/ClearButton.tsx

import { ArrayStateProps } from "../../ts/interface";
import { setArrayState, useRecoilState } from "../../ts/recoil";

export const ClearButton = ({ clear }: {clear: number}) => {

    const [array, setArray] = useRecoilState(setArrayState);
    
    const onClear = () => {
        const newArray = array.filter((arr: ArrayStateProps) => arr.id !== clear);
        
        setArray(newArray);
    }

    return (
        <>
            <button onClick={onClear}>삭제하기</button>
        </>
    );
}

 

styles/main.module.scss

.main_contents_wrap {
    width: 100%;
    max-width: 500px;
    margin: 0 auto;
    > div {
        margin: 1rem 0;
    }

    .array_wrap {
        > div {
            padding: 1rem 0;
            color: #222;
            > span { font-weight: 700; color: #DDD; margin-right: .3rem; }
        }
    }
}
반응형
반응형

npm

npm install @react-oauth/google
npm install jwt-decode

 

구글 API OAuth 사용자 인증 정보

구글 API 바로가기

내부 타입으로 할 경우 직접 선택한 유저만 사용 가능하고, 외부 타입으로 할 경우 모든 사용자가 사용 가능합니다.

 

 

 

Client ID와 Client Secret ID는 잘 저장해둡니다.

 

앱 게시를 하게되면 이전에 외부 타입으로 설정하는 것과 같습니다. ( 현재는 내부로 했기 때문에 이런식으로 표현되며, 다시 테스트로 돌아갈 수도 있습니다. )

테스트로 사용할 경우 아래 테스트 사용자를 추가할 수 있습니다. ( 추가된 사용자만 사용이 가능해집니다. )

 

.env.development ( 최상위 경로 src 상위 )

REACT_APP_CLIENT_ID = "클라이언트 아이디 ( 위에서 생성한 아이디 )"

 

react-app-env.d.ts ( 최상위 경로에 있는 파일 )

/// <reference types="react-scripts" />

declare namespace NodeJS {
    interface ProcessEnv {
        readonly NODE_ENV: 'development' | 'production' | 'test';
        readonly PUBLIC_URL: string;
        REACT_APP_CLIENT_ID: string;
    }
}

위 코드처럼 작성하면 REACT_APP_CLIENT_ID를 자동완성 시킬수 있습니다. ( 타입 지정 이라고 생각하면 편합니다. )

 

App.tsx

import { CredentialResponse, GoogleLogin, GoogleOAuthProvider } from "@react-oauth/google";
import jwtDecode from "jwt-decode";

interface GoogleDecodedProps {
    aud: string,
    azp: string,
    email: string,
    email_varified: boolean,
    exp: number,
    family_name: string,
    given_name: string,
    iat: number,
    iss: string,
    jti: string,
    name: string,
    nbf: number,
    picture: string,
    sub: string
}

export const App = () => {

    // 구글 클라이언트 아이디 정보
    const configValue = process.env.REACT_APP_CLIENT_ID;
    
    const onGoogleLogin = async (res: CredentialResponse) => {
        const decoded: GoogleDecodedProps = jwtDecode(JSON.stringify(res));
        
        console.log(decoded);
    }
    
    return (
        <article>
            <button className={styles.button_color_google}>
                <GoogleOAuthProvider clientId={configValue}>
                    <GoogleLogin onSuccess={credentialResponse => {onGoogleLogin(credentialResponse)}} onError={() => console.log("ERR")}></GoogleLogin>
                </GoogleOAuthProvider>
            </button>
        </article>
    )
}

jwtDecode()로 해독해주면 위 interface 내용과 같이 사용자 정보가 나오게 됩니다.

반응형

+ Recent posts