Senior Lead Software Engineer @NHN Dooray (2021~)

Lead Software Engineer @ProtoPie (2016 ~ 2021)

Microsoft MVP

TypeScript Korea User Group Organizer

Marktube (Youtube)

Woongjae Lee

이 웅재

브라우저와의 상호작용 살펴보기

  • 주소창에 직접 타이핑을 해서 전체 URL 을 변경할 수 있습니다.

  • 주소창에 직접 입력하여 변경하는 경우, 브라우저는 일반적으로 서버로부터 새로운 페이지를 로드하게 되므로 JavaScript 상태와 이벤트 리스너가 초기화됩니다.

  • CSR (Client Side Rendering) 에서는 보통 어떤 경로 (Path) 로 요청해도 일단 같은 SPA 리소스를 받아서 실행합니다.

  • 그래서 앱이 최초로 실행될 때 경로 (Path) 를 알아내면 됩니다. (window.location)

  • 그리고 나서 경로 (Path) 에 맞는 화면을 보여주게 됩니다.

  • 브라우저의 새로고침(재로딩)을 감지하는 직접적인 방법은 없습니다. 새로고침 버튼이 클릭되면 현재 페이지는 새로 로드되며, 모든 JavaScript의 상태와 이벤트 리스너가 초기화됩니다.

  • 주소창에 직접 타이핑을 해서 전체 URL 을 변경하는 것과 다르지 않습니다.

  • 그래서 앱이 최초로 실행될 때 경로 (Path) 를 알아내면 됩니다. (window.location)

  • 그리고 나서 경로 (Path) 에 맞는 화면을 보여주게 됩니다.

  • 브라우저 주소창 왼편의 이전 (go back), 다음 (go forward) 버튼을 클릭하면, 이전 혹은 다음 URL 로 이동합니다.

  • 브라우저 주소창 왼편의 이전 (go back), 다음 (go forward) 버튼을 우클릭하면, 이전 URL 의 목록이나 다음 URL 의 목록이 나오고, 클릭하면 그리로 이동합니다.

  • 이와 같은 행위를 감지할 수 있는 이벤트인 popstate 이벤트가 존재합니다. window.addEventListener('popstate', 함수) 를 통해 이벤트를 받아서 처리할 수 있습니다.

  • 이러한 목록은 window.history 를 통해 접근이 가능합니다.

  • 보안 이슈로 인해 모든 행위가 다 가능한 것은 아니지만, pushState 와 같은 메서드를 이용하면, 이 목록에 추가가 가능합니다. 이렇게하면, 주소창의 주소가 변경되고 히스토리 목록에도 추가가 됩니다.

  • 하지만, window.history.pushState 를 통해 변경을 할 때는 popstate 이벤트가 발생하지 않습니다.
    다른 방법으로 감지해야 합니다.

  • 랜더링된 도큐먼트에서 a 태그나 엘리먼트에 연결된 클릭 이벤트의 리스너로 URL 이동하게 할 경우가 많이 있습니다.

  • a 태그는 별도의 처리를 하지 않으면, 주소창에 입력하여 변경하는 것과 같은 방식으로 동작합니다.

  • 그래서 이렇게 동작하기 전에 a 태그로 들어온 이벤트를 preventDefault 로 막아주고,
    history.pushState 로 직접 주소창을 변경해야 합니다.

  • 버튼은 기본 동작이 URL 변경이 아니므로 기본 동작을 막아줄 필요는 없고,
    마찬가지로 history.pushState 로 직접 주소창을 변경해야 합니다.

  • 하지만, 앞에서 알아본 것처럼 pushState 로 변경하면, 이벤트로 감지할 수 없습니다.

<a href="https://www.google.com/">외부 사이트로 이동 하는 링크</a>

<a href="/about">내부 사이트에서 경로를 이동하는 링크</a>

<button>클릭하면 내부 사이트에서 경로를 이동하는 버튼</button>

➜ nvm use 18.17.1

➜ npm i yarn -g

➜ yarn create vite routing-without-history --template react-swc-ts

➜ cd routing-without-history

➜ yarn

➜ code .

➜ yarn dev

remix-run/history

  • history 라이브러리를 사용하면 자바스크립트가 실행되는 모든 곳에서 세션 히스토리를 쉽게 관리할 수 있습니다.

  • history 객체는 다양한 환경의 차이를 추상화하여 히스토리 스택을 관리하고, 탐색하고, 세션 간 상태를 유지할 수 있는 최소한의 API를 제공합니다.

  • 라이브러리의 용량이 매우 작기 때문에 레포지토리에서 찾아볼 수 있는 파일은 몇 개에 불과합니다.

  • history version 5는 React Router version 6 에서 사용됩니다.

➜ nvm use 18.17.1

➜ npm i yarn -g

➜ yarn create vite routing-with-history --template react-swc-ts

➜ cd routing-with-history

➜ yarn

➜ yarn add history

➜ code .

➜ yarn dev
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { createBrowserHistory } from "history";

const history = createBrowserHistory();

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <App history={history} />
  </React.StrictMode>
);
import { type BrowserHistory } from "history";
import { useEffect, useState } from "react";

const App: React.FC<{ history: BrowserHistory }> = ({ history }) => {
  const [pathname, setPathname] = useState(history.location.pathname);

  useEffect(() => {
    const unlisten = history.listen(({ location }) => {
      console.log("location", location);
      if (location.pathname === pathname) return;
      setPathname(location.pathname);
    });

    return () => {
      unlisten();
    };
  }, [history, pathname]);

  const goHome = () => {
    history.push("/");
  };

  const goAbout = (event: React.MouseEvent<HTMLAnchorElement>) => {
    event.preventDefault();
    const href = event.currentTarget.getAttribute("href");
    if (href === null) return;
    history.push(href);
  };

  console.log("render");

  return (
    <div>
      <h1>pathname: {pathname}</h1>
      <nav>
        <ul>
          <li>
            <button onClick={goHome}>Home</button>
          </li>
          <li>
            <a href="/about" onClick={goAbout}>
              About
            </a>
          </li>
        </ul>
      </nav>
      <div>
        {pathname === "/" && <h2>Home</h2>}
        {pathname === "/about" && <h2>About</h2>}
      </div>
    </div>
  );
};

export default App;

remix-run/react-router

  • React Router 는 가볍고 모든 기능을 갖춘 React 라이브러리용 라우팅 라이브러리입니다.

  • React Router 는 web, server (node.js), React Native 등 React 가 실행되는 모든 곳에서 실행됩니다.

  • React Router 레포지토리는 세 가지 패키지를 포함하는 monorepo 입니다:

    • (router)

      • history 포함

    • react-router

      • React Router 의 핵심이며, react-router-dom 과 react-router-native 모두를 위한 모든 핵심 기능을 제공합니다.

      • React Router 를 사용하는 경우, react-router 패키지에서 직접 import 하는 것이 아니라, react-router-dom 이나 react-router-native 에 필요한 모든 것이 있습니다.

      • 이 두 패키지는 모두 react-router 에서 모든 것을 export 해줍니다.

    • react-router-dom

    • react-router-native

Picking a Router

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},
    }),
  ],
})

React Router

By Woongjae Lee

React Router

  • 215