React Workshop Jun 2023

https://slides.com/woongjae/react-workshop-jun-2023

Senior Lead Software Engineer @NHN Dooray (2021~)

Lead Software Engineer @ProtoPie (2016 ~ 2021)

Microsoft MVP

TypeScript Korea User Group Organizer

Marktube (Youtube)

Woongjae Lee

이 웅재

실무 개발 환경

Node.js

- 가장 기본적인 개발 환경

- 클라이언트 개발 환경의 가장 중요한 축

- 현실적으로 한번 정해진 Node 버전을 올리기는 생각보다 어렵다.

- 개발 환경과 빌드 환경의 Node.js 버전은 반드시 같아야 한다.

- 배포 환경의 Node.js 버전은 같지 않아도 된다. Link

  • 내가 개발한 환경 그대로 다른 사람이 개발할 수 있는가

    • 실행 런타임이 같아야 한다. 같은 Node.js 버전

    • 내 소스 코드에서 사용하는 라이브러리들의 재현 재현이 가능한 디펜던시 정보

  • 내가 로컬에서 빌드한 환경 그대로 CI 서버에서 그대로 빌드가 가능한 것인가

    • 빌드를 하는 런타임이 같아야 한다. 같은 Node.js 버전

    • 빌드에 포함되는 라이브러리들의 재현 재현이 가능한 디펜던시 정보

$ nvm install

$ nvm use

패키지 매니저 전쟁

- 빠르면 좋겠고,

- 내가 개발한 환경 그대로 빌드 환경에서 사용되길 바라고

- npm 이 기본, yarn 의 도전, pnpm 의 도전, yarn berry 로의 진화

npm

- import 하면 해석하여, node_modules 에서 가져감

- 의존성 헬을 만든 장본인

- 이미 설치된 모듈의 다른 버전이 필요하다면 해당 모듈을 필요로 하는 모듈 아래에 둠.

- 설치할 때마다 상이한 구조를 가지게 될 수 있음.

- package-lock.json 과 npm ci

yarn v1

- node_modules 를 사용함

- 병렬 설치로 빠른 설치 속도

- yarn.lock 파일 생성을 통한 의존성 구조를 고정

- 모노레포 지원

npm i yarn -g

pnpm

- 여러 프로젝트에서 사용되는 dependencies 의 중복 저장을 막아야

- 디스크 공간 절약 및 설치 속도 향상

- 평탄하지 않은 node_modules 디렉토리 생성

yarn berry

- 팬텀 디펜던시 ㅜ

- zero install

nvm use 18

npm i yarn -g

yarn set version stable

mkdir yarn-berry-nextjs

cd yarn-berry-nextjs

yarn init

yarn add next react react-dom

yarn add @yarnpkg/sdks -D

yarn dlx @yarnpkg/sdks vscode
{
  "name": "yarn-berry-nextjs",
  "packageManager": "yarn@3.6.0",
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "next": "^13.4.5",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@yarnpkg/sdks": "^3.0.0-rc.45"
  }
}

React 18

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

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

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);

17

18

import logo from "./logo.svg";
import "./App.css";
import { useState } from "react";

function App() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("A");

  function click() {
    setCount(count + 1);
    setName("B");
  }

  console.debug(count, name);

  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          {count} {name}
        </p>
        <button onClick={click}>Learn React</button>
      </header>
    </div>
  );
}

export default App;

17 = 18

Automatic Batching

import logo from "./logo.svg";
import "./App.css";
import { useState } from "react";

function App() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("A");

  function click() {
    setTimeout(() => {
      setCount(count + 1);
      setName("B");
    }, 1000);
  }

  console.debug(count, name);

  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          {count} {name}
        </p>
        <button onClick={click}>Learn React</button>
      </header>
    </div>
  );
}

export default App;

17 != 18

Automatic Batching

React Concurrent Mode

  • 서스펜스를 사용하면 컴포넌트 트리의 일부가 아직 표시될 준비가 되지 않은 경우 해당 부분의 로딩 상태를 선언적으로 지정할 수 있다.

  • 몇 년 전에 Suspense의 제한된 버전을 도입했다. 하지만 지원되는 사용 사례는 React.lazy 를 사용한 코드 분할뿐이었고, 서버에서 렌더링할 때는 전혀 지원되지 않았다.

  • React 18에서는 서버에서 Suspense에 대한 지원을 추가하고, 동시 렌더링 기능을 사용하여 기능을 확장했다.

  • React 18의 Suspense 는  transition API 와 함께 사용할 때 가장 잘 작동한다. 트랜지션 도중 일시 중단하면 React는 이미 표시된 콘텐츠가 폴백으로 대체되는 것을 방지한다. 대신 React는 데이터가 충분히 로드될 때까지 렌더링을 지연시켜 로딩 상태가 나빠지는 것을 방지한다.

React 로 만드는 쇼핑몰 프로젝트

$ git clone https://github.com/2woongjae/prototype-shop.git

$ cd prototype-shop

$ nvm install 14.16.1

$ nvm use 14.16.1

$ npm ci

$ npm i react-router-dom
import Home from "./pages/Home";
import Checkout from "./pages/Checkout";
import AppStateProvider from "./providers/AppStateProvider";
import { createBrowserRouter, RouterProvider } from "react-router-dom";

const router = createBrowserRouter([
  {
    path: "/",
    element: <Home />,
  },
  {
    path: "/checkout",
    element: <Checkout />,
  },
]);

function App() {
  return (
    <AppStateProvider>
      <RouterProvider router={router} />
    </AppStateProvider>
  );
}

export default App;
import Header from "../components/Header";
import Prototypes from "../components/Prototypes";
import Orders from "../components/Orders";
import Footer from "../components/Footer";

export default function Home() {
  return (
    <>
      <Header />
      <div className="container">
        <Prototypes />
        <Orders />
        <Footer />
      </div>
    </>
  );
}
import { useNavigate } from "react-router-dom";

export default function Orders() {
  const navigate = useNavigate();

  const goOrderPage = useCallback(() => {
    navigate("/checkout");
  }, [navigate]);

  return (
    ...
          <button
            className="btn btn--secondary"
            style={{ width: "100%", marginTop: 10 }}
            onClick={goOrderPage}
          >
            Checkout
          </button>
    ...
  );
}
import { useMemo, useCallback } from "react";

import useOrders from "../hooks/useOrders";
import usePrototypes from "../hooks/usePrototypes";
import { useNavigate } from "react-router-dom";

export default function Checkout() {
  const orders = useOrders();
  const prototypes = usePrototypes();
  const navigate = useNavigate();

  const totalPrice = useMemo(() => {
    return orders
      .map((order) => {
        const { id, quantity } = order;
        const prototype = prototypes.find((p) => p.id === id);
        return prototype.price * quantity;
      })
      .reduce((l, r) => l + r, 0);
  }, [orders, prototypes]);

  const goHomePage = useCallback(() => {
    navigate("/");
  }, [navigate]);

  if (orders.length === 0) {
    return (
      <button
        className="btn btn--secondary"
        style={{ width: "100%", marginTop: 10 }}
        onClick={goHomePage}
      >
        Go Home
      </button>
    );
  }

  return (
    <div className="checkout-container">
      <h1>Checkout</h1>
      <div className="body">
        {orders.map((order) => {
          const { id } = order;
          const prototype = prototypes.find((p) => p.id === id);
          return (
            <div className="item" key={id}>
              <div className="img">
                <video src={prototype.thumbnail} />
              </div>
              <div className="content">
                <p className="title">
                  {prototype.title} x {order.quantity}
                </p>
              </div>
              <div className="action">
                <p className="price">$ {prototype.price * order.quantity}</p>
              </div>
            </div>
          );
        })}
      </div>
      <div className="total">
        <hr />
        <div className="item">
          <div className="content">Total</div>
          <div className="action">
            <div className="price">$ {totalPrice}</div>
          </div>
        </div>
        <div
          style={{
            textAlign: "center",
          }}
        >
          <button
            className="btn btn--primary"
            style={{ width: "485px", marginTop: 10 }}
          >
            Payment
          </button>
          <button
            className="btn btn--primary"
            style={{ width: "485px", marginTop: 10, marginLeft: 10 }}
            onClick={goHomePage}
          >
            Cancel
          </button>
        </div>
      </div>
    </div>
  );
}
.checkout-container {
  margin-left: auto;
  margin-right: auto;
  padding-left: 0.4rem;
  padding-right: 0.4rem;
  display: flex;
  flex-direction: column;
  min-height: 100vh;
  max-width: 1000px;
  margin-top: 50px;
  margin-bottom: 100px;
}

    - Redux, Redux Toolkit
    - Mobx
    - Recoil, Jotai

- Zustand

Redux, Redux Toolkit

사실상 전통적인 상태 관리의 표준

장점

 

  • 간단한 구조

    • Redux Toolkit 은 Redux 를 사용하는 데 필요한 복잡한 설정 작업들을 대신 처리해주기 때문에, 더 간단하고 직관적인 구조를 가질 수 있습니다.

  • 간편한 코드 작성

    • Redux Toolkit 은 createSlice() 함수와 createAsyncThunk() 함수를 제공하여, 보일러플레이트 코드를 줄이고 간단한 코드 작성을 가능하게 합니다.

  • 개발 생산성 향상

    • Redux Toolkit 을 사용하면 Redux 를 보다 쉽게 사용할 수 있기 때문에, 개발 생산성을 향상시킬 수 있습니다.

  • 최적화된 성능

    • Redux Toolkit 은 내부적으로 immer.js 와 Redux Toolkit RTK Query 를 사용하여 성능 최적화를 지원합니다.

단점

  • 일부 기능의 제한

    • Redux Toolkit 은 Redux 의 기능 중 일부를 대신 처리하기 때문에, 모든 기능을 지원하지는 않습니다. 따라서 일부 기능을 직접 구현해야 할 수도 있습니다.

  • 학습 곡선

    • Redux Toolkit 을 사용하기 위해서는 Redux 의 기본적인 개념과 구조를 이해하고 있어야 합니다. 따라서 Redux 에 익숙하지 않은 개발자에게는 학습 곡선이 있을 수 있습니다.

  • 세부적인 컨트롤의 부재

    • Redux Toolkit 은 Redux 를 간편하게 사용할 수 있도록 지원하기 때문에, 세부적인 컨트롤을 위해서는 Redux 의 기본 기능을 사용해야 합니다.

  • 의존성 추가

    • Redux Toolkit 을 사용하기 위해서는 redux 와 immer.js 등의 라이브러리에 대한 의존성이 추가됩니다.

[실습]

prototype-shop 을

redux toolkit 으로 작성하기

git clone https://github.com/nhn-kai/prototype-shop.git prototype-shop-redux-toolkit

cd prototype-shop-redux-toolkit

nvm use 16

npm ci

npm i @reduxjs/toolkit react-redux

npm start
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import { Provider } from "react-redux";
import create from "./redux/create";

const store = create();

ReactDOM.render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>,
  document.getElementById("root")
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
import { configureStore } from "@reduxjs/toolkit";
import rootReducer from "./modules/rootReducer";

export default function create() {
  const store = configureStore({
    reducer: rootReducer,
  });

  return store;
}
import prototypes from "./prototypes";
import orders from "./orders";

const rootReducer = { prototypes, orders };

export default rootReducer;
import { createSlice } from "@reduxjs/toolkit";
import { createNamespace } from "../utils";

const namespace = createNamespace("prototypes");

const initialState = {
  data: [
    {
      id: "pp-01",
      title: "Kids-story",
      artist: "Thomas Buisson",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Kids-story_1.mp4",
      price: 10,
      pieUrl: "https://cloud.protopie.io/p/8a6461ad85",
    },
    {
      id: "pp-02",
      title: "mockyapp",
      artist: "Ahmed Amr",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/mockyapp.mp4",
      price: 20,
      pieUrl: "https://cloud.protopie.io/p/27631ac9d5",
    },
    {
      id: "pp-03",
      title: "macOS Folder Concept",
      artist: "Dominik Kandravý",
      desc: "Folder concept prototype by Dominik Kandravý.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/macOS_Folder_Concept_-_Folder_concept.mp4",
      price: 30,
      pieUrl: "https://cloud.protopie.io/p/acde5ccdf9",
    },
    {
      id: "pp-04",
      title: "Translator",
      artist: "Tony Kim",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Translator.mp4",
      price: 40,
      pieUrl: "https://cloud.protopie.io/p/b91edba11d",
    },
    {
      id: "pp-05",
      title: "In-car voice control",
      artist: "Tony Kim",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/In-car_voice_control.mp4",
      price: 50,
      pieUrl: "https://cloud.protopie.io/p/6ec7e70d1a",
    },
    {
      id: "pp-06",
      title: "The Adventures of Proto",
      artist: "Richard Oldfield",
      desc: `Made exclusively for Protopie Playoff 2021
                    Shout up if you get stuck!
                    For the full experience. View in the Protopie App.
                    #PieDay #PlayOff #ProtoPie`,
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/The_Adventures_of_Proto.mp4",
      price: 60,
      pieUrl: "https://cloud.protopie.io/p/95ee13709f",
    },
    {
      id: "pp-07",
      title: "Sunglasses shop app",
      artist: "Mustafa Alabdullah",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/sunglasses_shop_app.mp4",
      price: 70,
      pieUrl: "https://cloud.protopie.io/p/6f336cac8c",
    },
    {
      id: "pp-08",
      title: "Alwritey—Minimalist Text Editor",
      artist: "Fredo Tan",
      desc: `This minimalist text editor prototype was made with ProtoPie by Fredo Tan.
                    ---
                    Inspired by Writty, a simple writing app by Carlos Yllobre. Try out Writty at https://writtyapp.com.
                    ---
                    ProtoPie is an interactive prototyping tool for all digital products.
                    ---
                    Learn more about ProtoPie at https://protopie.io.`,
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/minimalist-text-editor.mp4",
      price: 80,
      pieUrl: "https://cloud.protopie.io/p/946f88f8d3",
    },
    {
      id: "pp-09",
      title: "Voice search for TV",
      artist: "Tony Kim",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/TV.mp4",
      price: 90,
      pieUrl: "https://cloud.protopie.io/p/60ee64cda0",
    },
    {
      id: "pp-10",
      title: "Finance App Visual Interaction 2.0",
      artist: "Arpit Agrawal",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Credit_Card_App.mp4",
      price: 90,
      pieUrl:
        "https://cloud.protopie.io/p/09ce2fdf84/21?ui=true&mockup=true&touchHint=true&scaleToFit=true&cursorType=touch",
    },
    {
      id: "pp-11",
      title: "Whack-a-mole",
      artist: "Changmo Kang",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Whack_a_mole.mp4",
      price: 90,
      pieUrl: "https://cloud.protopie.io/p/ab796f897e",
    },
    {
      id: "pp-12",
      title: "Voice Note",
      artist: "Haerin Song",
      desc: `Made by Haerin Song
                    (Soda Design)`,
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Voice_note_with_sound_wave.mp4",
      price: 90,
      pieUrl: "https://cloud.protopie.io/p/7a0d6567d2",
    },
  ],
};

const { reducer } = createSlice({
  name: namespace,
  initialState,
});

export default reducer;
import { createSlice } from "@reduxjs/toolkit";
import { createNamespace } from "../utils";

const namespace = createNamespace("orders");

const initialState = { data: [] };

const {
  actions: { addToOrder, remove, removeAll },
  reducer,
} = createSlice({
  name: namespace,
  initialState,
  reducers: {
    addToOrder(state, action) {
      const id = action.payload.id;

      const finded = state.data.find((order) => order.id === id);

      if (finded === undefined) {
        state.data = [...state.data, { id: id, quantity: 1 }];
      } else {
        state.data = state.data.map((order) => {
          if (order.id === id) {
            return {
              id,
              quantity: order.quantity + 1,
            };
          } else {
            return order;
          }
        });
      }
    },
    remove(state, action) {
      const id = action.payload.id;
      state.data = state.data.filter((order) => order.id !== id);
    },
    removeAll(state) {
      state.data = [];
    },
  },
});

export default reducer;

export { addToOrder, remove, removeAll };
export const createNamespace = (namespace) => `@prototype-shop/${namespace}`;
import Header from "./components/Header";
import PrototypesContainer from "./containers/PrototypesContainer";
import OrdersContainer from "./containers/OrdersContainer";
import Footer from "./components/Footer";

function App() {
  return (
    <>
      <Header />
      <div className="container">
        <PrototypesContainer />
        <OrdersContainer />
        <Footer />
      </div>
    </>
  );
}

export default App;
import { useCallback } from "react";
import { addToOrder as addToOrderAction } from "../redux/modules/orders";
import Prototypes from "../components/Prototypes";
import { useSelector, useDispatch } from "react-redux";

export default function PrototypesContainer() {
  const prototypes = useSelector((state) => state.prototypes.data);
  const dispatch = useDispatch();

  const addToOrder = useCallback(
    (id) => {
      dispatch(addToOrderAction({ id }));
    },
    [dispatch]
  );

  return <Prototypes prototypes={prototypes} addToOrder={addToOrder} />;
}
const Prototypes = ({ prototypes, addToOrder }) => (
  <main>
    <div className="prototypes">
      {prototypes.map((prototype) => {
        const { id, thumbnail, title, price, desc, pieUrl } = prototype;
        const click = () => {
          addToOrder(id);
        };
        return (
          <div className="prototype" key={id}>
            <a href={pieUrl} target="_BLANK" rel="noreferrer">
              <div
                style={{
                  padding: "25px 0 33px 0",
                }}
              >
                <video
                  autoPlay
                  loop
                  playsInline
                  className="prototype__artwork prototype__edit"
                  src={thumbnail}
                  style={{
                    objectFit: "contain",
                  }}
                />
              </div>
            </a>

            <div className="prototype__body">
              <div className="prototype__title">
                <div className="btn btn--primary float--right" onClick={click}>
                  <i className="icon icon--plus" />
                </div>

                {title}
              </div>
              <p className="prototype__price">$ {price}</p>
              <p className="prototype__desc">{desc}</p>
            </div>
          </div>
        );
      })}
    </div>
  </main>
);

export default Prototypes;
import Orders from "../components/Orders";
import { useCallback } from "react";
import {
  remove as removeAction,
  removeAll as removeAllAction,
} from "../redux/modules/orders";
import { useSelector, useDispatch } from "react-redux";

export default function OrdersContainer() {
  const prototypes = useSelector((state) => state.prototypes.data);
  const orders = useSelector((state) => state.orders.data);
  const dispatch = useDispatch();

  const remove = useCallback(
    (id) => {
      dispatch(removeAction({ id }));
    },
    [dispatch]
  );

  const removeAll = useCallback(
    (id) => {
      dispatch(removeAllAction());
    },
    [dispatch]
  );

  return (
    <Orders
      prototypes={prototypes}
      orders={orders}
      remove={remove}
      removeAll={removeAll}
    />
  );
}
import { useMemo } from "react";

export default function Orders({ prototypes, orders, remove, removeAll }) {
  const totalPrice = useMemo(() => {
    return orders
      .map((order) => {
        const { id, quantity } = order;
        const prototype = prototypes.find((p) => p.id === id);
        return prototype.price * quantity;
      })
      .reduce((l, r) => l + r, 0);
  }, [orders, prototypes]);

  if (orders.length === 0) {
    return (
      <aside>
        <div className="empty">
          <div className="title">You don't have any orders</div>
          <div className="subtitle">Click on a + to add an order</div>
        </div>
      </aside>
    );
  }

  return (
    <aside>
      <div className="order">
        <div className="body">
          {orders.map((order) => {
            const { id } = order;
            const prototype = prototypes.find((p) => p.id === id);
            const click = () => {
              remove(id);
            };
            return (
              <div className="item" key={id}>
                <div className="img">
                  <video src={prototype.thumbnail} />
                </div>
                <div className="content">
                  <p className="title">
                    {prototype.title} x {order.quantity}
                  </p>
                </div>
                <div className="action">
                  <p className="price">$ {prototype.price * order.quantity}</p>
                  <button className="btn btn--link" onClick={click}>
                    <i className="icon icon--cross" />
                  </button>
                </div>
              </div>
            );
          })}
        </div>
        <div className="total">
          <hr />
          <div className="item">
            <div className="content">Total</div>
            <div className="action">
              <div className="price">$ {totalPrice}</div>
            </div>
            <button className="btn btn--link" onClick={removeAll}>
              <i className="icon icon--delete" />
            </button>
          </div>
          <button
            className="btn btn--secondary"
            style={{ width: "100%", marginTop: 10 }}
          >
            Checkout
          </button>
        </div>
      </div>
    </aside>
  );
}

MobX

리액티브 프로그래밍을 이용한 상태 관리

장점

  • 단순성

    • MobX 는 Redux 와 비교했을 때 상대적으로 단순합니다. MobX 는 state, action, reaction 의 세 가지 구성 요소로 이루어져 있으며, 이를 이용하여 간단하게 애플리케이션의 상태 관리를 할 수 있습니다.

  • 성능
    • MobX 는 반응형 프로그래밍(reactive programming)을 기반으로 하여, 상태 변화가 발생하면 해당 상태에 의존하는 모든 컴포넌트들이 자동으로 업데이트됩니다. 이를 통해 상태 변화에 대한 반응성을 높일 수 있으며, 불필요한 렌더링을 최소화할 수 있습니다.
  • 코드량
    • MobX를 사용하면 상태를 업데이트하기 위한 많은 코드를 작성할 필요가 없습니다. MobX는 자동으로 상태 변화를 감지하고, 이에 따라 업데이트를 처리하기 때문에, 코드량이 줄어듭니다.

단점

  • 학습 곡선

    • MobX 는 상대적으로 쉬운 상태 관리 라이브러리이지만, 다른 상태 관리 라이브러리와는 다른 개념을 사용하므로, 초기에 학습 곡선이 높을 수 있습니다.

  • 커뮤니티
    • MobX 는 상대적으로 작은 커뮤니티를 가지고 있어, 문제를 해결하기 위한 지원을 받기가 어렵거나, 필요한 도구나 라이브러리를 찾기가 어렵다는 단점이 있습니다.
  • 구현 방법
    • MobX 는 Redux 와 달리 공식적인 구현 방법이 없습니다. 따라서, 프로젝트에 따라 다른 구현 방법을 사용해야 하므로, 이를 파악하고 구현하는 데 시간이 소요될 수 있습니다.

리액티브 프로그래밍(reactive programming)

 

비동기적인 데이터 스트림을 다루는 프로그래밍 패러다임 중 하나입니다. 리액티브 프로그래밍에서는 데이터의 상태 변화에 대해 반응(react)하는 것이 중요합니다. 이를 위해 리액티브 프로그래밍에서는 데이터 스트림의 생성, 변환, 필터링, 결합 등을 위한 다양한 연산자를 제공합니다.

 

리액티브 프로그래밍의 주요 개념으로는 옵저버블(Observable)과 옵서버(Observer)가 있습니다. 옵저버블은 데이터 스트림을 나타내며, 옵서버는 옵저버블에서 발생하는 데이터 변화를 관찰하고 처리합니다. 따라서, 옵저버블에서 데이터 변화가 발생하면 옵서버는 이를 감지하여 처리하게 됩니다.

 

리액티브 프로그래밍은 비동기적인 데이터 처리를 위해 많이 사용됩니다. 비동기적인 데이터 처리는 이벤트 기반(event-driven)이거나, 콜백(callback) 기반이거나, 프로미스(promise)나, 비동기 함수(async/await) 등을 통해 구현됩니다. 이러한 비동기적인 데이터 처리를 효율적으로 구현하기 위해 리액티브 프로그래밍에서는 다양한 라이브러리와 프레임워크가 제공됩니다.

[실습]

prototype-shop 을

mobx 로 작성하기

git clone https://github.com/nhn-kai/prototype-shop.git prototype-shop-mobx

cd prototype-shop-mobx

nvm use 16

npm ci

npm i mobx mobx-react

npm start
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import PrototypesStore from "./stores/PrototypesStore";
import OrdersStore from "./stores/OrdersStore";
import { Provider } from "mobx-react";

const prototypesStore = new PrototypesStore();
const ordersStore = new OrdersStore();

ReactDOM.render(
  <React.StrictMode>
    <Provider prototypesStore={prototypesStore} ordersStore={ordersStore}>
      <App />
    </Provider>
  </React.StrictMode>,
  document.getElementById("root")
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
import { computed, makeObservable, observable } from "mobx";

export default class PrototypesStore {
  data = [
    {
      id: "pp-01",
      title: "Kids-story",
      artist: "Thomas Buisson",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Kids-story_1.mp4",
      price: 10,
      pieUrl: "https://cloud.protopie.io/p/8a6461ad85",
    },
    {
      id: "pp-02",
      title: "mockyapp",
      artist: "Ahmed Amr",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/mockyapp.mp4",
      price: 20,
      pieUrl: "https://cloud.protopie.io/p/27631ac9d5",
    },
    {
      id: "pp-03",
      title: "macOS Folder Concept",
      artist: "Dominik Kandravý",
      desc: "Folder concept prototype by Dominik Kandravý.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/macOS_Folder_Concept_-_Folder_concept.mp4",
      price: 30,
      pieUrl: "https://cloud.protopie.io/p/acde5ccdf9",
    },
    {
      id: "pp-04",
      title: "Translator",
      artist: "Tony Kim",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Translator.mp4",
      price: 40,
      pieUrl: "https://cloud.protopie.io/p/b91edba11d",
    },
    {
      id: "pp-05",
      title: "In-car voice control",
      artist: "Tony Kim",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/In-car_voice_control.mp4",
      price: 50,
      pieUrl: "https://cloud.protopie.io/p/6ec7e70d1a",
    },
    {
      id: "pp-06",
      title: "The Adventures of Proto",
      artist: "Richard Oldfield",
      desc: `Made exclusively for Protopie Playoff 2021
                    Shout up if you get stuck!
                    For the full experience. View in the Protopie App.
                    #PieDay #PlayOff #ProtoPie`,
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/The_Adventures_of_Proto.mp4",
      price: 60,
      pieUrl: "https://cloud.protopie.io/p/95ee13709f",
    },
    {
      id: "pp-07",
      title: "Sunglasses shop app",
      artist: "Mustafa Alabdullah",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/sunglasses_shop_app.mp4",
      price: 70,
      pieUrl: "https://cloud.protopie.io/p/6f336cac8c",
    },
    {
      id: "pp-08",
      title: "Alwritey—Minimalist Text Editor",
      artist: "Fredo Tan",
      desc: `This minimalist text editor prototype was made with ProtoPie by Fredo Tan.
                    ---
                    Inspired by Writty, a simple writing app by Carlos Yllobre. Try out Writty at https://writtyapp.com.
                    ---
                    ProtoPie is an interactive prototyping tool for all digital products.
                    ---
                    Learn more about ProtoPie at https://protopie.io.`,
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/minimalist-text-editor.mp4",
      price: 80,
      pieUrl: "https://cloud.protopie.io/p/946f88f8d3",
    },
    {
      id: "pp-09",
      title: "Voice search for TV",
      artist: "Tony Kim",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/TV.mp4",
      price: 90,
      pieUrl: "https://cloud.protopie.io/p/60ee64cda0",
    },
    {
      id: "pp-10",
      title: "Finance App Visual Interaction 2.0",
      artist: "Arpit Agrawal",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Credit_Card_App.mp4",
      price: 90,
      pieUrl:
        "https://cloud.protopie.io/p/09ce2fdf84/21?ui=true&mockup=true&touchHint=true&scaleToFit=true&cursorType=touch",
    },
    {
      id: "pp-11",
      title: "Whack-a-mole",
      artist: "Changmo Kang",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Whack_a_mole.mp4",
      price: 90,
      pieUrl: "https://cloud.protopie.io/p/ab796f897e",
    },
    {
      id: "pp-12",
      title: "Voice Note",
      artist: "Haerin Song",
      desc: `Made by Haerin Song
                    (Soda Design)`,
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Voice_note_with_sound_wave.mp4",
      price: 90,
      pieUrl: "https://cloud.protopie.io/p/7a0d6567d2",
    },
  ];

  constructor() {
    makeObservable(this, {
      data: observable,
      prototypes: computed,
    });
  }

  get prototypes() {
    return this.data;
  }
}
import { action, computed, makeObservable, observable } from "mobx";

export default class OrdersStore {
  data = [];

  constructor() {
    makeObservable(this, {
      data: observable,
      orders: computed,
      addToOrder: action,
      remove: action,
      removeAll: action,
    });
  }

  get orders() {
    return this.data;
  }

  addToOrder = (id) => {
    const finded = this.data.find((order) => order.id === id);

    if (finded === undefined) {
      this.data = [...this.data, { id, quantity: 1 }];
    } else {
      this.data = this.data.map((order) => {
        if (order.id === id) {
          return {
            id,
            quantity: order.quantity + 1,
          };
        } else {
          return order;
        }
      });
    }
  };

  remove = (id) => {
    this.data = this.data.filter((order) => order.id !== id);
  };

  removeAll = () => {
    this.data = [];
  };
}
import Header from "./components/Header";
import PrototypesContainer from "./containers/PrototypesContainer";
import OrdersContainer from "./containers/OrdersContainer";
import Footer from "./components/Footer";

function App() {
  return (
    <>
      <Header />
      <div className="container">
        <PrototypesContainer />
        <OrdersContainer />
        <Footer />
      </div>
    </>
  );
}

export default App;
import { inject, observer } from "mobx-react";
import Prototypes from "../components/Prototypes";

const PrototypesContainer = inject(({ prototypesStore, ordersStore }) => ({
  prototypesStore,
  ordersStore,
}))(
  observer(({ prototypesStore, ordersStore }) => {
    return (
      <Prototypes
        prototypes={prototypesStore.prototypes}
        addToOrder={ordersStore.addToOrder}
      />
    );
  })
);

export default PrototypesContainer;
const Prototypes = ({ prototypes, addToOrder }) => (
  <main>
    <div className="prototypes">
      {prototypes.map((prototype) => {
        const { id, thumbnail, title, price, desc, pieUrl } = prototype;
        const click = () => {
          addToOrder(id);
        };
        return (
          <div className="prototype" key={id}>
            <a href={pieUrl} target="_BLANK" rel="noreferrer">
              <div
                style={{
                  padding: "25px 0 33px 0",
                }}
              >
                <video
                  autoPlay
                  loop
                  playsInline
                  className="prototype__artwork prototype__edit"
                  src={thumbnail}
                  style={{
                    objectFit: "contain",
                  }}
                />
              </div>
            </a>

            <div className="prototype__body">
              <div className="prototype__title">
                <div className="btn btn--primary float--right" onClick={click}>
                  <i className="icon icon--plus" />
                </div>

                {title}
              </div>
              <p className="prototype__price">$ {price}</p>
              <p className="prototype__desc">{desc}</p>
            </div>
          </div>
        );
      })}
    </div>
  </main>
);

export default Prototypes;
import { inject, observer } from "mobx-react";
import Orders from "../components/Orders";

const OrdersContainer = inject(({ prototypesStore, ordersStore }) => ({
  prototypesStore,
  ordersStore,
}))(
  observer(({ prototypesStore, ordersStore }) => {
    return (
      <Orders
        prototypes={prototypesStore.prototypes}
        orders={ordersStore.orders}
        remove={ordersStore.remove}
        removeAll={ordersStore.removeAll}
      />
    );
  })
);

export default OrdersContainer;
import { useMemo } from "react";

export default function Orders({ prototypes, orders, remove, removeAll }) {
  const totalPrice = useMemo(() => {
    return orders
      .map((order) => {
        const { id, quantity } = order;
        const prototype = prototypes.find((p) => p.id === id);
        return prototype.price * quantity;
      })
      .reduce((l, r) => l + r, 0);
  }, [orders, prototypes]);

  if (orders.length === 0) {
    return (
      <aside>
        <div className="empty">
          <div className="title">You don't have any orders</div>
          <div className="subtitle">Click on a + to add an order</div>
        </div>
      </aside>
    );
  }

  return (
    <aside>
      <div className="order">
        <div className="body">
          {orders.map((order) => {
            const { id } = order;
            const prototype = prototypes.find((p) => p.id === id);
            const click = () => {
              remove(id);
            };
            return (
              <div className="item" key={id}>
                <div className="img">
                  <video src={prototype.thumbnail} />
                </div>
                <div className="content">
                  <p className="title">
                    {prototype.title} x {order.quantity}
                  </p>
                </div>
                <div className="action">
                  <p className="price">$ {prototype.price * order.quantity}</p>
                  <button className="btn btn--link" onClick={click}>
                    <i className="icon icon--cross" />
                  </button>
                </div>
              </div>
            );
          })}
        </div>
        <div className="total">
          <hr />
          <div className="item">
            <div className="content">Total</div>
            <div className="action">
              <div className="price">$ {totalPrice}</div>
            </div>
            <button className="btn btn--link" onClick={removeAll}>
              <i className="icon icon--delete" />
            </button>
          </div>
          <button
            className="btn btn--secondary"
            style={{ width: "100%", marginTop: 10 }}
          >
            Checkout
          </button>
        </div>
      </div>
    </aside>
  );
}
import { observer } from "mobx-react";
import Prototypes from "../components/Prototypes";
import { useContext } from "react";
import { MobXProviderContext } from "mobx-react";

const PrototypesContainer = observer(() => {
  const { prototypesStore, ordersStore } = useContext(MobXProviderContext);

  return (
    <Prototypes
      prototypes={prototypesStore.prototypes}
      addToOrder={ordersStore.addToOrder}
    />
  );
});

export default PrototypesContainer;

Recoil, Jotai, Zustand

좀더 가볍고 쉬운 새로운 상태 관리 => UI

[실습]

prototype-shop 을

recoil 로 작성하기

git clone https://github.com/nhn-kai/prototype-shop.git prototype-shop-recoil

cd prototype-shop-recoil

nvm use 16

npm ci

npm i recoil

npm start
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import { RecoilRoot } from "recoil";

ReactDOM.render(
  <React.StrictMode>
    <RecoilRoot>
      <App />
    </RecoilRoot>
  </React.StrictMode>,
  document.getElementById("root")
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
import Header from "./components/Header";
import PrototypesContainer from "./containers/PrototypesContainer";
import OrdersContainer from "./containers/OrdersContainer";
import Footer from "./components/Footer";

function App() {
  return (
    <>
      <Header />
      <div className="container">
        <PrototypesContainer />
        <OrdersContainer />
        <Footer />
      </div>
    </>
  );
}

export default App;
import { useCallback } from "react";
import { useRecoilValue, useRecoilState } from "recoil";
import prototypesState from "../recoil/atoms/prototypesState";
import ordersState from "../recoil/atoms/ordersState";
import Prototypes from "../components/Prototypes";

export default function PrototypesContainer() {
  const prototypes = useRecoilValue(prototypesState);
  const [orders, setOrders] = useRecoilState(ordersState);

  const addToOrder = useCallback(
    (id) => {
      const finded = orders.find((order) => order.id === id);

      if (finded === undefined) {
        setOrders([...orders, { id, quantity: 1 }]);
      } else {
        setOrders(
          orders.map((order) => {
            if (order.id === id) {
              return {
                id,
                quantity: order.quantity + 1,
              };
            } else {
              return order;
            }
          })
        );
      }
    },
    [orders, setOrders]
  );

  return <Prototypes prototypes={prototypes} addToOrder={addToOrder} />;
}
import { atom } from "recoil";

const prototypesState = atom({
  key: "prototypesState",
  default: [
    {
      id: "pp-01",
      title: "Kids-story",
      artist: "Thomas Buisson",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Kids-story_1.mp4",
      price: 10,
      pieUrl: "https://cloud.protopie.io/p/8a6461ad85",
    },
    {
      id: "pp-02",
      title: "mockyapp",
      artist: "Ahmed Amr",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/mockyapp.mp4",
      price: 20,
      pieUrl: "https://cloud.protopie.io/p/27631ac9d5",
    },
    {
      id: "pp-03",
      title: "macOS Folder Concept",
      artist: "Dominik Kandravý",
      desc: "Folder concept prototype by Dominik Kandravý.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/macOS_Folder_Concept_-_Folder_concept.mp4",
      price: 30,
      pieUrl: "https://cloud.protopie.io/p/acde5ccdf9",
    },
    {
      id: "pp-04",
      title: "Translator",
      artist: "Tony Kim",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Translator.mp4",
      price: 40,
      pieUrl: "https://cloud.protopie.io/p/b91edba11d",
    },
    {
      id: "pp-05",
      title: "In-car voice control",
      artist: "Tony Kim",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/In-car_voice_control.mp4",
      price: 50,
      pieUrl: "https://cloud.protopie.io/p/6ec7e70d1a",
    },
    {
      id: "pp-06",
      title: "The Adventures of Proto",
      artist: "Richard Oldfield",
      desc: `Made exclusively for Protopie Playoff 2021
                    Shout up if you get stuck!
                    For the full experience. View in the Protopie App.
                    #PieDay #PlayOff #ProtoPie`,
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/The_Adventures_of_Proto.mp4",
      price: 60,
      pieUrl: "https://cloud.protopie.io/p/95ee13709f",
    },
    {
      id: "pp-07",
      title: "Sunglasses shop app",
      artist: "Mustafa Alabdullah",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/sunglasses_shop_app.mp4",
      price: 70,
      pieUrl: "https://cloud.protopie.io/p/6f336cac8c",
    },
    {
      id: "pp-08",
      title: "Alwritey—Minimalist Text Editor",
      artist: "Fredo Tan",
      desc: `This minimalist text editor prototype was made with ProtoPie by Fredo Tan.
                    ---
                    Inspired by Writty, a simple writing app by Carlos Yllobre. Try out Writty at https://writtyapp.com.
                    ---
                    ProtoPie is an interactive prototyping tool for all digital products.
                    ---
                    Learn more about ProtoPie at https://protopie.io.`,
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/minimalist-text-editor.mp4",
      price: 80,
      pieUrl: "https://cloud.protopie.io/p/946f88f8d3",
    },
    {
      id: "pp-09",
      title: "Voice search for TV",
      artist: "Tony Kim",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/TV.mp4",
      price: 90,
      pieUrl: "https://cloud.protopie.io/p/60ee64cda0",
    },
    {
      id: "pp-10",
      title: "Finance App Visual Interaction 2.0",
      artist: "Arpit Agrawal",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Credit_Card_App.mp4",
      price: 90,
      pieUrl:
        "https://cloud.protopie.io/p/09ce2fdf84/21?ui=true&mockup=true&touchHint=true&scaleToFit=true&cursorType=touch",
    },
    {
      id: "pp-11",
      title: "Whack-a-mole",
      artist: "Changmo Kang",
      desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Whack_a_mole.mp4",
      price: 90,
      pieUrl: "https://cloud.protopie.io/p/ab796f897e",
    },
    {
      id: "pp-12",
      title: "Voice Note",
      artist: "Haerin Song",
      desc: `Made by Haerin Song
                    (Soda Design)`,
      thumbnail:
        "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Voice_note_with_sound_wave.mp4",
      price: 90,
      pieUrl: "https://cloud.protopie.io/p/7a0d6567d2",
    },
  ],
});

export default prototypesState;
import { atom } from "recoil";

const ordersState = atom({
  key: "ordersState",
  default: [],
});

export default ordersState;
import { useCallback, useMemo } from "react";
import { useRecoilValue, useRecoilState } from "recoil";
import prototypesState from "../recoil/atoms/prototypesState";
import ordersState from "../recoil/atoms/ordersState";
import Orders from "../components/Orders";

export default function OrdersContainer() {
  const prototypes = useRecoilValue(prototypesState);
  const [orders, setOrders] = useRecoilState(ordersState);

  const totalPrice = useMemo(() => {
    return orders
      .map((order) => {
        const { id, quantity } = order;
        const prototype = prototypes.find((p) => p.id === id);
        return prototype.price * quantity;
      })
      .reduce((l, r) => l + r, 0);
  }, [orders, prototypes]);

  const remove = useCallback(
    (id) => {
      setOrders((orders) => {
        return orders.filter((order) => order.id !== id);
      });
    },
    [setOrders]
  );
  const removeAll = useCallback(() => {
    setOrders([]);
  }, [setOrders]);

  return (
    <Orders
      prototypes={prototypes}
      orders={orders}
      totalPrice={totalPrice}
      remove={remove}
      removeAll={removeAll}
    />
  );
}
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root")
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
import { atom } from "jotai";

const prototypesState = atom([
  {
    id: "pp-01",
    title: "Kids-story",
    artist: "Thomas Buisson",
    desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
    thumbnail:
      "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Kids-story_1.mp4",
    price: 10,
    pieUrl: "https://cloud.protopie.io/p/8a6461ad85",
  },
  {
    id: "pp-02",
    title: "mockyapp",
    artist: "Ahmed Amr",
    desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
    thumbnail:
      "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/mockyapp.mp4",
    price: 20,
    pieUrl: "https://cloud.protopie.io/p/27631ac9d5",
  },
  {
    id: "pp-03",
    title: "macOS Folder Concept",
    artist: "Dominik Kandravý",
    desc: "Folder concept prototype by Dominik Kandravý.",
    thumbnail:
      "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/macOS_Folder_Concept_-_Folder_concept.mp4",
    price: 30,
    pieUrl: "https://cloud.protopie.io/p/acde5ccdf9",
  },
  {
    id: "pp-04",
    title: "Translator",
    artist: "Tony Kim",
    desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
    thumbnail:
      "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Translator.mp4",
    price: 40,
    pieUrl: "https://cloud.protopie.io/p/b91edba11d",
  },
  {
    id: "pp-05",
    title: "In-car voice control",
    artist: "Tony Kim",
    desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
    thumbnail:
      "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/In-car_voice_control.mp4",
    price: 50,
    pieUrl: "https://cloud.protopie.io/p/6ec7e70d1a",
  },
  {
    id: "pp-06",
    title: "The Adventures of Proto",
    artist: "Richard Oldfield",
    desc: `Made exclusively for Protopie Playoff 2021
                    Shout up if you get stuck!
                    For the full experience. View in the Protopie App.
                    #PieDay #PlayOff #ProtoPie`,
    thumbnail:
      "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/The_Adventures_of_Proto.mp4",
    price: 60,
    pieUrl: "https://cloud.protopie.io/p/95ee13709f",
  },
  {
    id: "pp-07",
    title: "Sunglasses shop app",
    artist: "Mustafa Alabdullah",
    desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
    thumbnail:
      "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/sunglasses_shop_app.mp4",
    price: 70,
    pieUrl: "https://cloud.protopie.io/p/6f336cac8c",
  },
  {
    id: "pp-08",
    title: "Alwritey—Minimalist Text Editor",
    artist: "Fredo Tan",
    desc: `This minimalist text editor prototype was made with ProtoPie by Fredo Tan.
                    ---
                    Inspired by Writty, a simple writing app by Carlos Yllobre. Try out Writty at https://writtyapp.com.
                    ---
                    ProtoPie is an interactive prototyping tool for all digital products.
                    ---
                    Learn more about ProtoPie at https://protopie.io.`,
    thumbnail:
      "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/minimalist-text-editor.mp4",
    price: 80,
    pieUrl: "https://cloud.protopie.io/p/946f88f8d3",
  },
  {
    id: "pp-09",
    title: "Voice search for TV",
    artist: "Tony Kim",
    desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
    thumbnail:
      "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/TV.mp4",
    price: 90,
    pieUrl: "https://cloud.protopie.io/p/60ee64cda0",
  },
  {
    id: "pp-10",
    title: "Finance App Visual Interaction 2.0",
    artist: "Arpit Agrawal",
    desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
    thumbnail:
      "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Credit_Card_App.mp4",
    price: 90,
    pieUrl:
      "https://cloud.protopie.io/p/09ce2fdf84/21?ui=true&mockup=true&touchHint=true&scaleToFit=true&cursorType=touch",
  },
  {
    id: "pp-11",
    title: "Whack-a-mole",
    artist: "Changmo Kang",
    desc: "This prototype was made with ProtoPie, the interactive prototyping tool for all digital products.",
    thumbnail:
      "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Whack_a_mole.mp4",
    price: 90,
    pieUrl: "https://cloud.protopie.io/p/ab796f897e",
  },
  {
    id: "pp-12",
    title: "Voice Note",
    artist: "Haerin Song",
    desc: `Made by Haerin Song
                    (Soda Design)`,
    thumbnail:
      "https://prototype-shop.s3.ap-northeast-2.amazonaws.com/thumbnails/Voice_note_with_sound_wave.mp4",
    price: 90,
    pieUrl: "https://cloud.protopie.io/p/7a0d6567d2",
  },
]);

export default prototypesState;
import { atom } from "jotai";

const ordersState = atom([]);

export default ordersState;
import { useCallback } from "react";
import { useAtom } from "jotai";
import prototypesState from "../jotai/atoms/prototypesState";
import ordersState from "../jotai/atoms/ordersState";
import Prototypes from "../components/Prototypes";

export default function PrototypesContainer() {
  const [prototypes] = useAtom(prototypesState);
  const [orders, setOrders] = useAtom(ordersState);

  const addToOrder = useCallback(
    (id) => {
      const finded = orders.find((order) => order.id === id);

      if (finded === undefined) {
        setOrders([...orders, { id, quantity: 1 }]);
      } else {
        setOrders(
          orders.map((order) => {
            if (order.id === id) {
              return {
                id,
                quantity: order.quantity + 1,
              };
            } else {
              return order;
            }
          })
        );
      }
    },
    [orders, setOrders]
  );

  return <Prototypes prototypes={prototypes} addToOrder={addToOrder} />;
}
import { useCallback, useMemo } from "react";
import { useAtom } from "jotai";
import prototypesState from "../jotai/atoms/prototypesState";
import ordersState from "../jotai/atoms/ordersState";
import Orders from "../components/Orders";

export default function OrdersContainer() {
  const [prototypes] = useAtom(prototypesState);
  const [orders, setOrders] = useAtom(ordersState);

  const totalPrice = useMemo(() => {
    return orders
      .map((order) => {
        const { id, quantity } = order;
        const prototype = prototypes.find((p) => p.id === id);
        return prototype.price * quantity;
      })
      .reduce((l, r) => l + r, 0);
  }, [orders, prototypes]);

  const remove = useCallback(
    (id) => {
      setOrders((orders) => {
        return orders.filter((order) => order.id !== id);
      });
    },
    [setOrders]
  );
  const removeAll = useCallback(() => {
    setOrders([]);
  }, [setOrders]);

  return (
    <Orders
      prototypes={prototypes}
      orders={orders}
      totalPrice={totalPrice}
      remove={remove}
      removeAll={removeAll}
    />
  );
}

- React Query

- SWR

npx create-react-app react-swr-test

cd react-swr-test

nvm use 16

npm i swr

npm start
import useSWR from "swr";

const fetcher = (...args) => fetch(...args).then((res) => res.json());

export default function SwrTest1() {
  const { data, error, isLoading } = useSWR(
    "https://api.github.com/users",
    fetcher
  );

  console.log(data, error, isLoading);

  return (
    <div>
      <h1>SwrTest</h1>
      {isLoading && <p>Loading...</p>}
      {error && <p>Error!</p>}
      {data && (
        <div>
          {data.map((user) => (
            <p key={user.login}>{user.login}</p>
          ))}
        </div>
      )}
    </div>
  );
}
import useSWR from "swr";

const fetcher = (...args) => fetch(...args).then((res) => res.json());

function useGithubUser() {
  const { data, error, isLoading } = useSWR(
    "https://api.github.com/users",
    fetcher
  );

  return { user: data, isLoading, error };
}

function UserList() {
  const { user, isLoading, error } = useGithubUser();

  return (
    <div>
      {isLoading && <p>Loading...</p>}
      {error && <p>Error!</p>}
      {user && <p>{user.map(({ login }) => login).join(",")}</p>}
    </div>
  );
}

function UserCount() {
  const { user, isLoading, error } = useGithubUser();

  return (
    <div>
      {isLoading && <p>Loading...</p>}
      {error && <p>Error!</p>}
      {user && <div>{user.length}</div>}
    </div>
  );
}

export default function SwrTest2() {
  return (
    <div>
      <h1>SwrTest2</h1>
      <UserList />
      <UserCount />
    </div>
  );
}

Next.js 로 블로그 만들기

nvm install 18

nvm use 18

npx create-next-app my-blog-new

Server Components

  • 클라이언트 단에서 돌아가지 않는 데이터베이스 및 API 등의 백엔드 서비스에 접근할 수 있다.

  • 보안 키 값들이 클라이언트 단에 드러나지 않도록 지킬 수 있다.

  • data fetching 과 렌더링을 동일한 환경에서 수행할 수 있다.

  • 서버에 렌더링을 캐싱할 수 있다.

  • 번들링할 자바스크립트 양을 줄일 수 있다.

  • data fetching이 필요한 경우 Server

  • 백엔드 자원에 접근해야 하는 경우 Server

  • 클라이언트에 드러내면 안 되는 민감한 정보가 있을 때 Server

  • 자바스크립트 코드를 줄여야 할 때 Server

  • click, change 리스너 등을 사용하여 대화형(상호작용) 컨텐츠를 구현하려는 경우 Client

  • '상태(state)'을 활용하는 경우 Client

  • 브라우저 상에서만 지원하는 API(예: local storage와 같은 웹 스토리지를 다루는 API)를 사용하는 경우 Client

 

Next.js에 따르면 우선 위와 같은 기준을 두고, 가능한 경우에는 서버 컴포넌트로 만들고

따로 클라이언트 컴포넌트로 구현이 필요한 부분들만 추출하는 편이 좋다고 한다.

cd my-blog-new

npm i @sanity/client antd @ant-design/icons @sanity/block-content-to-react react-syntax-highlighter @sanity/client dayjs

npm run dev
import styles from "./page.module.css";
import SanityService from "../services/SanityService";
import Header from "./components/Header";
import Footer from "./components/Footer";
import BlogHeadline from "./components/BlogHeadline";
import BlogMainPost from "./components/BlogMainPost";
import BlogList from "./components/BlogList";

async function fetchData() {
  const sanityService = new SanityService();
  const home = await sanityService.getHome();
  const posts = await sanityService.getPosts();

  return {
    home,
    posts,
  };
}

export default async function Home() {
  const { posts, home } = await fetchData();

  const mainPost = posts.find((p) => p.slug === home.mainPostUrl);
  const otherPosts = posts.filter((p) => p.slug !== home.mainPostUrl);

  return (
    <div className={styles.container}>
      <Header />
      <BlogHeadline />
      <BlogMainPost {...mainPost} />
      <BlogList posts={otherPosts} />
      <Footer />
    </div>
  );
}
import Header from "../../components/Header";
import SanityService from "../../../services/SanityService";
import Footer from "../../components/Footer";
import BlogPostDetail from "../../components/BlogPostDetail";
import BlogMainPost from "../../components/BlogMainPost";
import styles from "./page.module.css";

export async function generateStaticParams() {
  const posts = await new SanityService().getPosts();

  return posts.map((post) => ({
    slug: post.slug,
  }));
}

async function fetchData(params) {
  const posts = await new SanityService().getPosts();

  const post = posts.find((p) => p.slug === params.slug);

  return post;
}

export default async function Page({ params }) {
  const data = await fetchData(params);

  console.log(data);

  return (
    <div className={styles.container}>
      <Header />
      <BlogMainPost {...data} />
      <BlogPostDetail blocks={data.content} />
      <Footer />
    </div>
  );
}
npx serve out

Sanity v3

  • schema 작성법 변경사항

  • 사라진 URL Metadata 플러그인

  • 코드 인풋

nvm install 18

nvm use 18

npm create sanity@latest -- --template blog --create-project "My Blog Contents New" --dataset production
import {defineField, defineType} from 'sanity'

export default defineType({
  name: 'imageGallery',
  title: 'ImageGallery',
  type: 'object',
  fields: [
    defineField({
      name: 'caption',
      title: 'Caption',
      type: 'string',
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: 'images',
      title: 'Images',
      type: 'array',
      options: {
        layout: 'grid',
      },
      of: [
        {
          name: 'image',
          title: 'Image',
          type: 'image',
          hotspot: true,
          fields: [
            {
              name: 'alt',
              title: 'alt',
              type: 'string',
              options: {
                isHighlighted: true,
              },
              validation: (Rule) => Rule.required(),
            },
          ],
          validation: (Rule) => Rule.required(),
        },
      ],
      validation: (Rule) => Rule.required().max(4),
    }),
  ],
})
import {defineField, defineType} from 'sanity'

export default defineType({
  name: 'imageGallery',
  title: 'ImageGallery',
  type: 'object',
  fields: [
    defineField({
      name: 'caption',
      title: 'Caption',
      type: 'string',
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: 'images',
      title: 'Images',
      type: 'array',
      options: {
        layout: 'grid',
      },
      of: [
        {
          name: 'image',
          title: 'Image',
          type: 'image',
          hotspot: true,
          fields: [
            {
              name: 'alt',
              title: 'alt',
              type: 'string',
              options: {
                isHighlighted: true,
              },
              validation: (Rule) => Rule.required(),
            },
          ],
          validation: (Rule) => Rule.required(),
        },
      ],
      validation: (Rule) => Rule.required().max(4),
    }),
  ],
})
npm i @sanity/code-input
import {defineConfig} from 'sanity'
import {deskTool} from 'sanity/desk'
import {visionTool} from '@sanity/vision'
import {schemaTypes} from './schemas'

import {codeInput} from '@sanity/code-input'

export default defineConfig({
  name: 'default',
  title: 'My Blog Contents New',

  projectId: 'hzh6fivm',
  dataset: 'production',

  plugins: [deskTool(), visionTool(), codeInput()],

  schema: {
    types: schemaTypes,
  },
})
export default defineType({
  title: 'Block Content',
  name: 'blockContent',
  type: 'array',
  of: [
    ...
    // You can add additional types here. Note that you can't use
    // primitive types such as 'string' and 'number' in the same array
    // as a block type.
    defineArrayMember({
      type: 'image',
      options: {hotspot: true},
    }),
    defineArrayMember({
      type: 'video',
      options: {hotspot: true},
    }),
    defineArrayMember({
      type: 'code',
      options: {hotspot: true},
    }),
    defineArrayMember({
      type: 'link',
      options: {hotspot: true},
    }),
    defineArrayMember({
      type: 'imageGallery',
      options: {hotspot: true},
    }),
  ],
})