React Workshop 2023

https://slides.com/woongjae/react-workshop-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

이 웅재

Function Component

Hooks

componentDidUpdate() {
  setTimeout(() => {
     console.log(`You clicked ${this.state.count} times`);
  }, 3000);
}
import React, { useState } from 'react';

const Example2 = () => {
  const [count, setCount] = useState(0);

  function click() {
    setCount(count + 1);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={click}>Click me</button>
    </div>
  );
};

export default Example2;

const [count, setCount] = useState(0);

const [스테이트 값, 스테이트 변경 함수] = useState(스테이트 초기값);

  • 컴포넌트 사이에서 상태와 관련된 로직을 재사용하기 어렵습니다.

    • ​컨테이너 방식 말고, 상태와 관련된 로직

  • 복잡한 컴포넌트들은 이해하기 어렵습니다.

  • Class 는 사람과 기계를 혼동시킵니다.

    • ​컴파일 단계에서 코드를 최적화하기 어렵게 만든다.

  • ​this.state 는 로직에서 레퍼런스를 공유하기 때문에 문제가 발생할 수 있다.

    • ​좋은 것일까 ?

  • useState

    • state 를 대체 할 수 있다.

  • useEffect

    • 라이프 사이클 훅을 대체 할 수 있다.

      • componentDidMount

      • componentDidUpdate

      • componentWillUnmount

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });
  
  function click() {
    setCount(count + 1);
  }
  
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={click}>Click me</button>
    </div>
  );
}

useEffect 자세히 알아보기

첫 번째 랜더링 (1)

from 리액트

"컴포넌트야,

state가 0 일 때의 UI를 보여줘."

첫 번째 랜더링 (2)

from 컴포넌트

  • "여기 랜더링 결과물로 <p>You clicked 0 times</p> 가 있어."
  • "그리고 모든 처리가 끝나고 이 이펙트를 실행하는 것을 잊지 마."
    () => { document.title = 'You clicked 0 times' }

첫 번째 랜더링 (3)

from 리액트

"좋아. UI를 업데이트 하겠어.
이봐 브라우저, 나 DOM에 뭘 좀 추가하려고 해."

첫 번째 랜더링 (4)

from 브라우저

"좋아, 화면에 그려줄게."

첫 번째 랜더링 (5)

리액트

좋아, 이제 컴포넌트 컴포넌트가 준 이펙트를 실행할거야.

() => { document.title = 'You clicked 0 times' } 를 실행.

클릭 후, 랜더링 (1)

from 컴포넌트

"이봐 리액트, 내 상태를 1 로 변경해줘."

클릭 후, 랜더링 (2)

from 리액트

"상태가 1 일때의 UI를 줘."

클릭 후, 랜더링 (3)

from 컴포넌트

  • "여기 랜더링 결과물로 <p>You clicked 1 times</p> 가 있어."

  • "그리고 모든 처리가 끝나고 이 이펙트를 실행하는 것을 잊지 마."
    () => { document.title = 'You clicked 1 times' }.

클릭 후, 랜더링 (4)

from 리액트

"좋아. UI를 업데이트 하겠어.
이봐 브라우저, 나 DOM에 뭘 좀 추가하려고 해."

 

클릭 후, 랜더링 (5)

from 브라우저

"좋아, 화면에 그려줄게."

클릭 후, 랜더링 (6)

리액트

좋아, 이제 컴포넌트 컴포넌트가 준 이펙트를 실행할거야.

() => { document.title = 'You clicked 1 times' } 를 실행.

// hooks/useWindowWidth.js

import { useState, useEffect } from 'react';

export default function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);
  useEffect(() => {
    const onResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', onResize);
    return () => {
      window.removeEventLister('resize', onResize);
    };
  }, []);

  return width;
}

나만의 훅 만들기

useHasMounted vs withHasMounted

// hocs/withHasMounted.js

import React from 'react';

export default function withHasMounted(Component) {
  class WrapperComponent extends React.Component {
    state = {
      hasMounted: false,
    };
    componentDidMount() {
      this.setState({
        hasMounted: true,
      });
    }
    render() {
      const { hasMounted } = this.state;
      return <Component {...this.props} hasMounted={hasMounted} />;
    }
  }

  WrapperComponent.displayName = `withHasMounted(${Component.name})`;

  return WrapperComponent;
}

withHasMounted (HOC)

// hooks/useHasMounted.js

import { useState, useEffect } from 'react';

export default function useHasMounted() {
  const [hasMounted, setHasMounted] = useState(false);
  useEffect(() => {
    setHasMounted(true);
  }, []);

  return hasMounted;
}

useHasMounted (Custom Hook)

useReducer

  • 다수의 하윗값을 포함하는 복잡한 정적 로직을 만드는 경우

  • 다음 state가 이전 state에 의존적인 경우

  • Redux 를 안다면 쉽게 사용 가능

import React, { useReducer, useEffect } from 'react';

const Example8 = ({ count }) => {
  const [state, dispatch] = useReducer(reducer, { count });
  useEffect(() => {
    setTimeout(() => {
      dispatch({ type: 'PLUS' });
    }, 2000);
  }, []);

  function click() {
    dispatch({ type: 'PLUS' });
  }

  return (
    <div>
      <p>You clicked {state.count} times</p>
      <button onClick={click}>Click me</button>
    </div>
  );
};

export default Example8;

const [state, dispatch] = useReducer(reducer, initialState, init?)

const reducer = (state, action) => {
  if (action.type === 'PLUS') {
    return {
      count: state.count + 1,
    };
  }
  return state;
};
import React, { useState } from 'react';

function sum(persons) {
  console.log('sum...');
  return persons.map(person => person.age).reduce((l, r) => l + r, 0);
}

const Example9 = () => {
  const [value, setValue] = useState('');
  const [persons] = useState([{ name: 'Mark', age: 38 }, { name: 'Hanna', age: 27 }]);

  function change(e) {
    setValue(e.target.value);
  }

  const count = sum(persons);

  return (
    <div>
      <input value={value} onChange={change} />
      <p>{count}</p>
    </div>
  );
};

export default Example9;

실제로 변하지 않더라도, 매번 실행되는 어떤 값

import React, { useState, useMemo } from 'react';

function sum(persons) {
  console.log('sum...');
  return persons.map(person => person.age).reduce((l, r) => l + r, 0);
}

const Example9 = () => {
  const [value, setValue] = useState('');
  const [persons] = useState([{ name: 'Mark', age: 38 }, { name: 'Hanna', age: 27 }]);

  function change(e) {
    setValue(e.target.value);
  }

  const count = useMemo(() => sum(persons), [persons]);

  return (
    <div>
      <input value={value} onChange={change} />
      <p>{count}</p>
    </div>
  );
};

export default Example9;

const 디펜던시 변경 없으면 고정 = useMemo(함수, 디펜던시)

import React, { useState } from 'react';

const Example10 = () => {
  const [value, setValue] = useState('');
  const [persons, setPersons] = useState([
    { id: 0, name: 'Mark', age: 38 },
    { id: 1, name: 'Hanna', age: 27 },
  ]);

  function change(e) {
    setValue(e.target.value);
  }

  function click(id) {
    setPersons(
      persons => persons.map(person =>
        person.id === id
          ? { ...person, age: person.age + 1 }
          : { ...person },
      )
    );
  }

  return (
    <div>
      <input value={value} onChange={change} />
      {persons.map(person => (
        <Person {...person} key={person.id} click={click} />
      ))}
    </div>
  );
};

export default Example10;

함수가 매번 새로 생성되는 경우, 최적화의 어려움 (feat. React.memo)

const Person = React.memo(({ id, name, age, click }) => {
  console.log('Person...');
  function onClick() {
    click(id);
  }
  return (
    <div>
      {name}, {age} <button onClick={onClick}>+</button>
    </div>
  );
});
import React, { useState } from 'react';

const Example10 = () => {
  const [value, setValue] = useState('');
  const [persons, setPersons] = useState([
    { id: 0, name: 'Mark', age: 38 },
    { id: 1, name: 'Hanna', age: 27 },
  ]);

  function change(e) {
    setValue(e.target.value);
  }

  const click = useCallback(id => {
    setPersons(persons => {
      return persons.map(person =>
        person.id === id
          ? {
              ...person,
              age: person.age + 1,
            }
          : {
              ...person,
            },
      );
    });
  }, []);

  return (
    <div>
      <input value={value} onChange={change} />
      {persons.map(person => (
        <Person {...person} key={person.id} click={click} />
      ))}
    </div>
  );
};

export default Example10;

useCallback

const Person = React.memo(({ id, name, age, click }) => {
  console.log('Person...');
  function onClick() {
    click(id);
  }
  return (
    <div>
      {name}, {age} <button onClick={onClick}>+</button>
    </div>
  );
});
import React, { useRef, useEffect, useState } from 'react';

const Example11 = () => {
  const [count, setCount] = useState(0);
  const inputCreateRef = React.createRef();
  const inputUseRef = useRef();
  console.log(inputCreateRef.current);
  console.log(inputUseRef.current);
  useEffect(() => {
    setTimeout(() => {
      setCount(count => count + 1);
    }, 1000);
  });
  return (
    <div>
      <p>{count}</p>
      <input ref={inputCreateRef} />
      <input ref={inputUseRef} />
    </div>
  );
};

export default Example11;

createRef vs useRef

Optimizing Performance

필요할 때만 랜더한다.

Reconciliation

  • 랜더 전후의 일치 여부를 판단하는 규칙

  • 서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 만들어낸다.

  • 개발자가 key prop 을 통해,
    여러 렌더링 사이에서 어떤 자식 엘리먼트가 변경되지 않아야 할지 표시해 줄 수 있다.

class App extends React.Component {
  state = {
    count: 0
  };
  componentDidMount() {
    setInterval(() => {
      this.setState({
        count: this.state.count + 1
      });
    }, 1000);
  }
  render() {
    if (this.state.count % 2 === 0) {
      return (
        <div>
          <Foo />
        </div>
      );
    }
    return (
      <span>
        <Foo />
      </span>
    );
  }
}
class Foo extends React.Component {
  componentDidMount() {
    console.log("Foo componentDidMount");
  }

  componentWillUnmount() {
    console.log("Foo componentWillUnmount");
  }

  render() {
    return <p>Foo</p>;
  }
}

엘리먼트의 타입이 다른 경우

class App extends React.Component {
  state = {
    count: 0
  };
  componentDidMount() {
    setInterval(() => {
      this.setState({
        count: this.state.count + 1
      });
    }, 1000);
  }
  render() {
    if (this.state.count % 2 === 0) {
      return <div className="before" title="stuff" />;
    }
    return <div className="after" title="stuff" />;
  }
}

DOM 엘리먼트의 타입이 같은 경우 (1)

class App extends React.Component {
  state = {
    count: 0
  };
  componentDidMount() {
    setInterval(() => {
      this.setState({
        count: this.state.count + 1
      });
    }, 1000);
  }
  render() {
    if (this.state.count % 2 === 0) {
      return <div style={{ color: "red", fontWeight: "bold" }} />;
    }
    return <div style={{ color: "green", fontWeight: "bold" }} />;
  }
}

DOM 엘리먼트의 타입이 같은 경우 (2)

class App extends React.Component {
  state = {
    count: 0
  };
  componentDidMount() {
    setInterval(() => {
      this.setState({
        count: this.state.count + 1
      });
    }, 1000);
  }
  render() {
    if (this.state.count % 2 === 0) {
      return <Foo name="Mark" />;
    }
    return <Foo name="Anna" />;
  }
}

같은 타입의 컴포넌트 엘리먼트

class Foo extends React.Component {
  state = {};

  componentDidMount() {
    console.log("Foo componentDidMount");
  }

  componentWillUnmount() {
    console.log("Foo componentWillUnmount");
  }

  static getDerivedStateFromProps(nextProps, prevState) {
    console.log("Foo getDerivedStateFromProps", nextProps, prevState);
    return {};
  }

  render() {
    console.log("Foo render");
    return <p>Foo</p>;
  }
}
class App extends React.Component {
  state = {
    count: 0
  };
  componentDidMount() {
    setTimeout(() => {
      this.setState({
        count: this.state.count + 1
      });
    }, 3000);
  }
  render() {
    if (this.state.count % 2 === 0) {
      return (
        <ul>
          <Foo>first</Foo>
          <Foo>second</Foo>
        </ul>
      );
    }
    return (
      <ul>
        <Foo>first</Foo>
        <Foo>second</Foo>
        <Foo>third</Foo>
      </ul>
    );
  }
}

자식에 대한 재귀적 처리 (1)

class Foo extends React.Component {
  state = {};

  componentDidMount() {
    console.log("Foo componentDidMount", this.props.children);
  }

  componentWillUnmount() {
    console.log("Foo componentWillUnmount");
  }

  static getDerivedStateFromProps(nextProps, prevState) {
    console.log("Foo getDerivedStateFromProps", nextProps, prevState);
    return {};
  }

  render() {
    console.log("Foo render", this.props.children);
    return <p>{this.props.children}</p>;
  }
}
class App extends React.Component {
  state = {
    count: 0
  };
  componentDidMount() {
    setTimeout(() => {
      this.setState({
        count: this.state.count + 1
      });
    }, 3000);
  }
  render() {
    if (this.state.count % 2 === 0) {
      return (
        <ul>
          <Foo>second</Foo>
          <Foo>third</Foo>
        </ul>
      );
    }
    return (
      <ul>
        <Foo>first</Foo>
        <Foo>second</Foo>
        <Foo>third</Foo>
      </ul>
    );
  }
}

자식에 대한 재귀적 처리 (2)

class Foo extends React.Component {
  state = {};

  componentDidMount() {
    console.log("Foo componentDidMount", this.props.children);
  }

  componentWillUnmount() {
    console.log("Foo componentWillUnmount");
  }

  static getDerivedStateFromProps(nextProps, prevState) {
    console.log("Foo getDerivedStateFromProps", nextProps, prevState);
    return {};
  }

  render() {
    console.log("Foo render", this.props.children);
    return <p>{this.props.children}</p>;
  }
}
class App extends React.Component {
  state = {
    count: 0
  };
  componentDidMount() {
    setTimeout(() => {
      this.setState({
        count: this.state.count + 1
      });
    }, 3000);
  }
  render() {
    if (this.state.count % 2 === 0) {
      return (
        <ul>
          <Foo key="2">second</Foo>
          <Foo key="3">third</Foo>
        </ul>
      );
    }
    return (
      <ul>
        <Foo key="1">first</Foo>
        <Foo key="2">second</Foo>
        <Foo key="3">third</Foo>
      </ul>
    );
  }
}

자식에 대한 재귀적 처리 (3)

class Foo extends React.Component {
  state = {};

  componentDidMount() {
    console.log("Foo componentDidMount", this.props.children);
  }

  componentWillUnmount() {
    console.log("Foo componentWillUnmount");
  }

  static getDerivedStateFromProps(nextProps, prevState) {
    console.log("Foo getDerivedStateFromProps", nextProps, prevState);
    return {};
  }

  render() {
    console.log("Foo render", this.props.children);
    return <p>{this.props.children}</p>;
  }
}
class App extends React.Component {
  state = {
    text: "",
    persons: [
      { id: 1, name: "Mark", age: 37 },
      { id: 2, name: "Anna", age: 26 },
    ]
  };
  render() {
    console.log("App render");
    const { text, persons } = this.state;
    return (
      <div>
        <input type="text" value={text} onChange={this._change} />
        <button onClick={this._click}>click</button>
        <ul>
          {persons.map(p => (
            <Person {...p} key={p.id} />
          ))}
        </ul>
      </div>
    );
  }

  _change = e => {
    this.setState({
      ...this.state,
      text: e.target.value
    });
  };

  _click = () => {
    console.log(this.state.text);
  };
}

setState 와 함께 일어나는 일

class Person extends React.Component {
  render() {
    console.log("Person render");

    const { name, age } = this.props;
    return (
      <ul>
        {name} / {age}
      </ul>
    );
  }
}
class App extends React.Component {
  state = {
    text: "",
    persons: [
      { id: 1, name: "Mark", age: 37 },
      { id: 2, name: "Anna", age: 26 },
    ]
  };
  render() {
    console.log("App render");
    const { text, persons } = this.state;
    return (
      <div>
        <input type="text" value={text} onChange={this._change} />
        <button onClick={this._click}>click</button>
        <ul>
          {persons.map(p => (
            <Person {...p} key={p.id} />
          ))}
        </ul>
      </div>
    );
  }

  _change = e => {
    this.setState({
      ...this.state,
      text: e.target.value
    });
  };

  _click = () => {
    console.log(this.state.text);
  };
}

shouldComponentUpdate

class Person extends React.Component {
  shouldComponentUpdate(previousProps) {
    for (const key in this.props) {
      if (previousProps[key] !== this.props[key]) {
        return true;
      }
    }
    return false;
  }
  render() {
    console.log("Person render");

    const { name, age } = this.props;
    return (
      <ul>
        {name} / {age}
      </ul>
    );
  }
}
class App extends React.Component {
  state = {
    text: "",
    persons: [
      { id: 1, name: "Mark", age: 37 },
      { id: 2, name: "Anna", age: 26 },
    ]
  };
  render() {
    console.log("App render");
    const { text, persons } = this.state;
    return (
      <div>
        <input type="text" value={text} onChange={this._change} />
        <button onClick={this._click}>click</button>
        <ul>
          {persons.map(p => (
            <Person {...p} key={p.id} />
          ))}
        </ul>
      </div>
    );
  }

  _change = e => {
    this.setState({
      ...this.state,
      text: e.target.value
    });
  };

  _click = () => {
    console.log(this.state.text);
  };
}

PureComponent

class Person extends React.PureComponent {
  render() {
    console.log("Person render");

    const { name, age } = this.props;
    return (
      <ul>
        {name} / {age}
      </ul>
    );
  }
}
class App extends React.Component {
  state = {
    text: "",
    persons: [
      { id: 1, name: "Mark", age: 37 },
      { id: 2, name: "Anna", age: 26 },
    ]
  };
  render() {
    console.log("App render");
    const { text, persons } = this.state;
    return (
      <div>
        <input type="text" value={text} onChange={this._change} />
        <button onClick={this._click}>click</button>
        <ul>
          {persons.map(p => (
            <Person {...p} key={p.id} onClick={() => {}} />
          ))}
        </ul>
      </div>
    );
  }

  _change = e => {
    this.setState({
      ...this.state,
      text: e.target.value
    });
  };

  _click = () => {
    console.log(this.state.text);
  };
}

onClick={() => {}}

class Person extends React.PureComponent {
  render() {
    console.log("Person render");

    const { name, age } = this.props;
    return (
      <ul>
        {name} / {age}
      </ul>
    );
  }
}
class App extends React.Component {
  state = {
    text: "",
    persons: [
      { id: 1, name: "Mark", age: 37 },
      { id: 2, name: "Anna", age: 26 },
    ]
  };
  render() {
    console.log("App render");
    const { text, persons } = this.state;
    return (
      <div>
        <input type="text" value={text} onChange={this._change} />
        <button onClick={this._click}>click</button>
        <ul>
          {persons.map(p => (
            <Person {...p} key={p.id} onClick={() => {}} />
          ))}
        </ul>
      </div>
    );
  }

  _change = e => {
    this.setState({
      ...this.state,
      text: e.target.value
    });
  };

  _click = () => {
    console.log(this.state.text);
  };
}

React.memo

const Person = React.memo(props => {
  console.log("Person render");

  const { name, age } = props;
  return (
    <ul>
      {name} / {age}
    </ul>
  );
});

훅을 왜 쓰는 것인가?

상태 활용에 대한 공통 처리

기본 훅에 대한 고찰

useMemo, useCallback

커스텀 훅에 대한 고찰

훅은 서로 공유되는 것이 아니다. 각각 따로 돌아가는 것

Context API

전역 상태 관리

하위 컴포넌트 전체에 데이터를 공유하는 법

  • 데이터를 Set 하는 놈

    • 가장 상위 컴포넌트 => 프로바이더

  • 데이터를 Get 하는 놈

    • 모든 하위 컴포넌트에서 접근 가능

      • ​컨슈머로 하는 방법

      • 클래스 컴포넌트의 this.context 로 하는 방법

      • 펑셔널 컴포넌트의 useContext 로 하는 방법

데이터를 Set 하기

  1. 일단 컨텍스트를 생성한다.

  2. 컨텍스트.프로바이더 를 사용한다.

  3. ​value 를 사용

import React from 'react';

const PersonContext = React.createContext();

export default PersonContext;
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import PersonContext from './contexts/PersonContext';

const persons = [
  { id: 0, name: 'Mark', age: 38 },
  { id: 1, name: 'Hanna', age: 27 },
];

ReactDOM.render(
  <PersonContext.Provider value={persons}>
    <App />
  </PersonContext.Provider>,
  document.getElementById('root'),
);

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

데이터를 Get 하기 (1) - Consumer

  1. 컨텍스트를 가져온다.

  2. 컨텍스트.컨슈머를 사용한다.

  3. ​value 를 사용

import React from 'react';
import PersonContext from '../contexts/PersonContext';

const Example1 = () => (
  <PersonContext.Consumer>
    {value => <ul>{JSON.stringify(value)}</ul>}
  </PersonContext.Consumer>
);

export default Example1;
import React from 'react';
import logo from './logo.svg';
import './App.css';
import Example1 from './components/Example1';

export default function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <Example1 />
      </header>
    </div>
  );
}

데이터를 Get 하기 (2) - class

  1. static contextType 에 컨텍스트를 설정한다.

  2. this.context => value 이다.

import React from 'react';
import PersonContext from '../contexts/PersonContext';

export default class Example2 extends React.Component {
  static contextType = PersonContext;

  render() {
    return <ul>{JSON.stringify(this.context)}</ul>;
  }
}

// Example2.contextType = PersonContext;
import React from 'react';
import logo from './logo.svg';
import './App.css';
import Example1 from './components/Example1';
import Example2 from './components/Example2';

export default function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <Example1 />
        <Example2 />
      </header>
    </div>
  );
}

데이터를 Get 하기 (3) - functional

  1. useContext 로 컨텍스트를 인자로 호출한다.

  2. useContext 의 리턴이 value 이다.

import React, { useContext } from 'react';
import PersonContext from '../contexts/PersonContext';

const Example3 = () => {
  const value = useContext(PersonContext);

  return <ul>{JSON.stringify(value)}</ul>;
};

export default Example3;
import React from 'react';
import logo from './logo.svg';
import './App.css';
import Example1 from './components/Example1';
import Example2 from './components/Example2';
import Example3 from './components/Example3';

export default function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <Example1 />
        <Example2 />
        <Example3 />
      </header>
    </div>
  );
}

    - 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>
  );
}

실무 개발 환경

Node.js

- 가장 기본적인 개발 환경

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

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

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

nvm use

패키지 매니저 전쟁

- 빠르면 좋겠고,

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

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

npm

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

- 의존성 헬을 만든 장본인

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

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

yarn v1

- node_modules 를 사용함

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

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

- 모노레포 지원

npm i yarn -g

pnpm

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

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

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

yarn berry

- 팬텀 디펜던시 ㅜ

- zero install

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-test",
  "packageManager": "yarn@3.4.1",
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "next": "^13.2.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@types/node": "18.14.1",
    "@types/react": "18.0.28",
    "@yarnpkg/sdks": "^3.0.0-rc.39",
    "typescript": "4.9.5"
  }
}

타입스크립트 활용

npx create-react-app --template=typescript prototype-shop-ts

cd prototype-shop-ts

npm start
import React from "react";
import {
  type OrderData,
  type PrototypeData,
} from "../providers/AppStateProvider";

interface AppStateContextProps {
  prototypes: PrototypeData[];
  orders: OrderData[];
  addToOrder: (id: string) => void;
  remove: (id: string) => void;
  removeAll: () => void;
}

const AppStateContext = React.createContext<AppStateContextProps | null>(null);

export default AppStateContext;
import React, { useCallback, useState } from "react";
import AppStateContext from "../contexts/AppStateContext";

interface AppStateProviderProps {
  children: React.ReactNode;
}

export interface PrototypeData {
  id: string;
  title: string;
  artist: string;
  desc: string;
  thumbnail: string;
  price: number;
  pieUrl: string;
}

export interface OrderData {
  id: string;
  quantity: number;
}

const AppStateProvider: React.FC<AppStateProviderProps> = ({ children }) => {
  const [prototypes] = useState<PrototypeData[]>([
    {
      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 [orders, setOrders] = useState<OrderData[]>([]);

  // [{id, quantity: 1}]
  const addToOrder = useCallback((id: string) => {
    setOrders((orders) => {
      const finded = orders.find((order) => order.id === id);

      if (finded === undefined) {
        return [...orders, { id, quantity: 1 }];
      } else {
        return orders.map((order) => {
          if (order.id === id) {
            return {
              id,
              quantity: order.quantity + 1,
            };
          } else {
            return order;
          }
        });
      }
    });
  }, []);
  const remove = useCallback((id: string) => {
    setOrders((orders) => {
      return orders.filter((order) => order.id !== id);
    });
  }, []);
  const removeAll = useCallback(() => {
    setOrders([]);
  }, []);

  return (
    <AppStateContext.Provider
      value={{
        prototypes,
        orders,
        addToOrder,
        remove,
        removeAll,
      }}
    >
      {children}
    </AppStateContext.Provider>
  );
};

export default AppStateProvider;
import { useContext } from "react";
import AppStateContext from "../contexts/AppStateContext";

export default function usePrototypes() {
  const state = useContext(AppStateContext);

  if (state === null) {
    throw new Error("state is null");
  }

  const { prototypes } = state;

  return prototypes;
}
import { useContext } from "react";
import AppStateContext from "../contexts/AppStateContext";

export default function useOrders() {
  const state = useContext(AppStateContext);

  if (state === null) {
    throw new Error("state is null");
  }

  const { orders } = state;

  return orders;
}
import { useContext } from "react";
import AppStateContext from "../contexts/AppStateContext";

export default function useActions() {
  const state = useContext(AppStateContext);

  if (state === null) {
    throw new Error("state is null");
  }

  const { addToOrder, remove, removeAll } = state;

  return { addToOrder, remove, removeAll };
}
import React from "react";
import useActions from "../hooks/useActions";
import usePrototypes from "../hooks/usePrototypes";

const Prototypes: React.FC = () => {
  const prototypes = usePrototypes();
  const { addToOrder } = useActions();

  return (
    <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 { useMemo } from "react";
import useActions from "../hooks/useActions";
import useOrders from "../hooks/useOrders";
import usePrototypes from "../hooks/usePrototypes";

const Orders: React.FC = () => {
  const orders = useOrders();
  const prototypes = usePrototypes();
  const { remove, removeAll } = useActions();

  const totalPrice = useMemo(() => {
    return orders
      .map((order) => {
        const { id, quantity } = order;
        const prototype = prototypes.find((p) => p.id === id);

        if (prototype === undefined) return 0;

        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);
            };

            if (prototype === undefined) {
              return <p>에러 문구</p>;
            }

            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>
  );
};

export default Orders;

스토리북

cd prototype-shop-ts

npx

npx storybook init

npm run storybook
import "../src/index.css";

export const parameters = {
  actions: { argTypesRegex: "^on[A-Z].*" },
  controls: {
    matchers: {
      color: /(background|color)$/i,
      date: /Date$/,
    },
  },
};
import React from "react";

import Header from "./Header";

export default {
  /* 👇 The title prop is optional.
   * See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
   * to learn how to generate automatic titles
   */
  title: "Header",
  component: Header,
};

export const Default = () => <Header />;
Default.storyName = "기본";
import React from "react";

import Footer from "./Footer";

export default {
  /* 👇 The title prop is optional.
   * See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
   * to learn how to generate automatic titles
   */
  title: "Footer",
  component: Footer,
};

export const Default = () => <Footer />;
Default.storyName = "기본";
import React from "react";
import { ComponentStory } from "@storybook/react";
import { action } from "@storybook/addon-actions";

import Prototypes from "./Prototypes";

export default {
  /* 👇 The title prop is optional.
   * See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
   * to learn how to generate automatic titles
   */
  title: "Prototypes",
  component: Prototypes,
};

const Template: ComponentStory<typeof Prototypes> = (args) => (
  <Prototypes {...args} />
);

export const Default = Template.bind({});
Default.storyName = "기본";
Default.args = {
  prototypes: [
    {
      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",
    },
  ],
  addToOrder: action("addToOrder"),
};
import React from "react";
import { ComponentStory } from "@storybook/react";
import { action } from "@storybook/addon-actions";

import Orders from "./Orders";

export default {
  /* 👇 The title prop is optional.
   * See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
   * to learn how to generate automatic titles
   */
  title: "Orders",
  component: Orders,
};

const Template: ComponentStory<typeof Orders> = (args) => <Orders {...args} />;

export const Default = Template.bind({});
Default.storyName = "주문이 없을 경우";
Default.args = {
  prototypes: [
    {
      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",
    },
  ],
  orders: [],
  remove: action("remove"),
  removeAll: action("removeAll"),
};

export const Exist = Template.bind({});
Exist.storyName = "주문이 있을 경우";
Exist.args = {
  prototypes: [
    {
      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",
    },
  ],
  orders: [{ id: "pp-01", quantity: 3 }],
  remove: action("remove"),
  removeAll: action("removeAll"),
};

리액트와 Next.js 천하

  • 서버 사이드 렌더링: Next.js는 서버 사이드 렌더링을 지원하여, 초기 로딩 속도를 높이고 검색 엔진 최적화(SEO)를 개선할 수 있습니다.

  • 정적 사이트 생성: Next.js는 정적 사이트 생성을 지원하여, 사전 렌더링된 페이지를 제공할 수 있습니다. 이를 통해 초기 로딩 속도와 사용자 경험을 개선할 수 있습니다.

  • 파일 시스템 라우팅: Next.js는 파일 시스템 라우팅을 지원하여, 페이지 라우팅을 간편하게 처리할 수 있습니다.

  • Hot Module Replacement: Next.js는 Hot Module Replacement(HMR)을 지원하여, 빠른 개발 및 디버깅을 가능케 합니다.

  • Automatic Code Splitting: Next.js는 Automatic Code Splitting을 지원하여, 필요한 코드만 로드하여 초기 로딩 속도를 개선할 수 있습니다.

  • CSS-in-JS 지원: Next.js는 CSS-in-JS를 지원하여, 컴포넌트 단위의 스타일링을 가능케 합니다.

  • TypeScript 지원: Next.js는 TypeScript를 지원하여, 타입 안정성을 개선할 수 있습니다.

  • Manual Setup

  • create-next-app

  • pages/index.js → /

  • pages/blog/index.js → /blog

  • pages/blog/first-post.js
    → /blog/first-post

  • pages/dashboard/settings/username.js
    → /dashboard/settings/username

  • pages/blog/[slug].js
    → /blog/:slug (/blog/hello-world)

  • pages/[username]/settings.js
    → /:username/settings (/foo/settings)

  • pages/post/[...all].js
    → /post/* (/post/2020/id/title)

gyp

node-gyp는 Node.js의 네이티브 확장 모듈(native addon module)을 빌드하기 위한 도구입니다. Node.js는 자바스크립트로 작성된 언어로 개발되었지만, C++와 같은 네이티브 언어로 작성된 모듈을 사용할 수 있습니다. node-gyp는 이러한 네이티브 확장 모듈을 빌드하기 위해 사용됩니다.

node-gyp는 GYP를 기반으로 하며, 네이티브 모듈을 빌드하기 위해 필요한 C++ 컴파일러, 빌드 도구 및 라이브러리를 제공합니다. 이를 통해 개발자는 C++로 작성된 네이티브 확장 모듈을 Node.js에서 사용할 수 있게 됩니다.

node-gyp는 node-gyp configure, node-gyp build, node-gyp clean과 같은 명령어를 제공합니다. configure 명령어는 네이티브 확장 모듈을 빌드하기 위한 환경을 설정하고, build 명령어는 환경을 기반으로 네이티브 확장 모듈을 빌드합니다. clean 명령어는 빌드된 파일을 삭제합니다.

node-gyp는 Node.js의 네이티브 확장 모듈을 빌드하기 위한 표준 도구이며, 전 세계의 Node.js 개발자들에게 널리 사용되고 있습니다.
node-gyp를 사용하는 과정에서 가장 많이 발생하는 에러 중 하나는 바이너리 모듈이 Node.js의 버전과 호환되지 않는 경우입니다. Node.js는 빈번하게 업데이트 되기 때문에, 예전에 빌드된 바이너리 모듈이 최신 버전의 Node.js와 호환되지 않을 수 있습니다.

또 다른 일반적인 에러는 개발자가 사용하는 운영체제에서 필요한 의존성 패키지가 설치되어 있지 않은 경우입니다. 이 경우, C++ 컴파일러 및 관련 라이브러리와 같은 패키지를 수동으로 설치해야 합니다.

또한, Windows 운영체제에서는 Visual Studio 또는 Visual C++ Build Tools를 설치하지 않은 경우에도 node-gyp를 사용하는 데 문제가 발생할 수 있습니다. 이 경우, Visual Studio 또는 Visual C++ Build Tools를 설치해야 합니다.

이외에도 node-gyp를 사용하는 과정에서 다양한 에러가 발생할 수 있습니다. 에러 메시지를 정확하게 파악하고, 필요한 의존성 패키지와 도구를 설치하고, 빌드 환경을 구성하는 등의 작업이 필요할 수 있습니다.

인증 처리에 관한 설계

로그인 페이지 구현
인증 처리를 위해서는 로그인 페이지가 필요합니다. 로그인 페이지에서는 사용자의 아이디와 비밀번호를 입력받고, 백엔드 서버로 인증 요청을 보내는 기능을 구현합니다.

인증 처리 기능 구현
로그인 페이지에서 입력한 사용자의 아이디와 비밀번호를 서버로 전송하여 인증을 처리합니다. 서버에서는 입력된 정보를 검증하고, 검증 결과에 따라 인증 성공 여부를 반환합니다. 인증에 성공하면 서버에서 JWT(JSON Web Token)를 발급하고, 해당 토큰을 클라이언트 측에서 저장합니다.

인증 정보 저장 기능 구현
클라이언트 측에서는 JWT를 저장하고, 로그인 이후의 요청에서 해당 토큰을 서버로 전송하여 인증을 처리합니다. JWT를 저장하는 방법은 쿠키, 로컬 스토리지, 세션 스토리지 등이 있습니다.
인증 여부 체크 기능 구현
인증 정보가 저장된 상태에서 다른 페이지로 이동할 때마다 인증 여부를 체크해야 합니다. 이를 위해 보통 미들웨어를 사용하여 인증 정보가 있는지 체크하고, 없다면 로그인 페이지로 이동시킵니다.

로그아웃 기능 구현
로그아웃 기능은 클라이언트 측에서 저장된 JWT를 삭제하고, 서버에서는 해당 토큰을 무효화하는 기능을 구현합니다.

이때, 보안에 관련된 중요한 정보인 JWT는 적절한 보안 방법을 사용하여 저장하고 관리해야 합니다. 또한, 클라이언트 측에서는 JWT를 직접 수정하거나 변경하는 것이 불가능하도록 처리해야 합니다.
클라이언트 측에서 JWT를 직접 수정하거나 변경하는 것을 방지하기 위해서는 다음과 같은 방법을 사용할 수 있습니다.

HttpOnly 속성 사용
HttpOnly 속성은 쿠키에 설정할 수 있는 속성으로, 이 속성이 설정된 쿠키는 JavaScript에서 접근할 수 없습니다. 따라서, JWT를 쿠키에 저장하고 HttpOnly 속성을 적용하면 클라이언트 측에서 직접 JWT를 수정하거나 변경하는 것을 방지할 수 있습니다.

서명(Signature) 사용
JWT는 헤더, 페이로드, 서명으로 이루어져 있습니다. 서명은 JWT를 생성할 때 사용한 비밀키를 이용하여 생성되는 값으로, JWT가 변경되었는지 여부를 확인할 수 있는 값입니다. 따라서, JWT를 생성할 때 사용한 비밀키를 안전하게 보관하고, 서명이 유효하지 않은 JWT는 클라이언트 측에서 거부하는 방법으로 JWT의 무단 변경을 방지할 수 있습니다.
JWT 유효기간 설정
JWT에는 유효기간을 설정할 수 있습니다. 유효기간이 만료된 JWT는 클라이언트 측에서 거부되기 때문에, JWT를 일정 기간 동안만 유효하게 설정하여 JWT의 무단 변경을 방지할 수 있습니다.

JWT 리프레시 토큰 사용
JWT 리프레시 토큰은 일정 기간이 지나면 만료되는 JWT 대신 새로운 JWT를 발급받을 수 있는 토큰입니다. JWT 리프레시 토큰을 사용하면, 일정 기간마다 클라이언트 측에서 서버로부터 새로운 JWT를 발급받아 사용함으로써 JWT의 무단 변경을 방지할 수 있습니다.

위와 같은 방법을 사용하여 JWT의 무단 변경을 방지할 수 있습니다. 또한, 서버 측에서는 JWT를 안전하게 저장하고 관리하여 JWT의 무단 사용을 방지해야 합니다.

React Workshop

By Woongjae Lee

React Workshop

  • 734