Redux 스타일 Store 직접 구현

type Reducer<S, A> = (state: S, action: A) => S
type Listener<S> = (state: S) => void

interface Store<S, A> {
  getState: () => S
  subscribe: (listener: Listener<S>) => () => void
  dispatch: (action: A) => A
}

function createStore<S, A>(
  reducer: Reducer<S, A>,
  preloadedState: S
): Store<S, A> {
  let currentState: S = preloadedState
  let listeners: Listener<S>[] = []

  function getState(): S {
    return currentState
  }

  function subscribe(listener: Listener<S>): () => void {
    listener(currentState)

    listeners.push(listener)

    return function unsubscribe() {
      listeners = listeners.filter((l) => l !== listener)
    }
  }

  function dispatch(action: A): A {
    currentState = reducer(currentState, action)

    listeners.forEach((listener) => {
      listener(currentState)
    })

    return action
  }

  return {
    subscribe,
    getState,
    dispatch,
  }
}

type Item = {
  body: string
}

type State = {
  items: Item[]
}

type Action = {
  type: 'ADD'
  payload: Item
}

const store = createStore<State, Action>(
  (state, action) => {
    switch (action.type) {
      case 'ADD':
        return {
          ...state,
          items: state.items.concat(action.payload),
        }
      default:
        return state
    }
  },
  {
    items: [],
  }
)

store.subscribe((state) => {
  console.log(state.items)
})

store.dispatch({
  type: 'ADD',
  payload: {
    body: 'hello',
  },
})
#484
import * as React from 'react'

export type NonEmptyString<T> = Exclude<T, ''>

type Props<T> = {
  src: NonEmptyString<T>
}

function Image<T extends string>({ src }: Props<T>) {
  return <img src={src} alt="" />
}

function App() {
  return (
    <>
      {/* @ts-expect-error */}
      <Image src="" />

      <Image src="ab" />
    </>
  )
}
#483
import * as React from 'react'
import { IsEqual } from 'type-plus'

type ModalProps =
  | {
      variant: 'no-title'
    }
  | {
      variant: 'title'
      title: string
    }

export const Modal = (props: ModalProps) => {
  if (props.variant === 'no-title') {
    return <div>No title</div>
  } else {
    type Test = IsEqual<
      typeof props,
      {
        variant: 'title'
        title: string
      }
    >

    return <div>Title: {props.title}</div>
  }
}

export const Test = () => {
  return (
    <div>
      <Modal variant="title" title="Hello" />
      <Modal variant="no-title" />

      {/* @ts-expect-error */}
      <Modal />
      <Modal
        variant="no-title"
        // @ts-expect-error
        title="Oh dear"
      />
    </div>
  )
}
#482
import * as React from 'react'

type Prettify<T> = {
  [K in keyof T]: T[K]
} & {}

type T = React.ComponentProps<'div'>

type P = Prettify<T>
#481

React 제네릭 컴포넌트 패턴 - <T extends Record<string, any>>로 row/item 타입 추론

import * as React from 'react'

type Props<TRow> = {
  rows: TRow[]
  renderRow: (row: TRow, index: number) => React.ReactNode
}

const Table = <TRow extends Record<string, any>>({
  rows,
  renderRow,
}: Props<TRow>) => {
  return (
    <table>
      <tbody>{rows.map((row, index) => renderRow(row, index))}</tbody>
    </table>
  )
}

function App() {
  return (
    <Table
      rows={[{ name: 'lee' }]}
      renderRow={(row, index) => (
        <tr key={index}>
          <td>{row.name}</td>
        </tr>
      )}
    />
  )
}

row.name 이 되는 이유 — TS 는 Props<TRow> 와 넘긴 값을 나란히 놓고 TRow 를 줍는다.

Props<TRow> 와 넘긴 props 를 칸마다 나란히 놓는다. rows: TRow[] 자리에는 { name: string }[] 이 와서 배열 안의 맨몸 TRow 자리에서 { name: string } 을 후보로 줍는다. renderRow 의 row 는 타입을 안 적었으니 주울 게 없다. extends Record<string, any> 는 넘긴 값이 아니라 후보가 아니고 검사에만 쓰인다. 후보로 TRow = { name: string } 이 정해지면 그 타입이 거꾸로 renderRow 의 row 로 흘러가서 row.name 을 쓸 수 있다.

후보가 여럿이면 자리에 따라 고른다.

rows={dogs} 에서는 값을 내주는 쪽(공변) 후보 Dog, renderRow={(row: Animal) => …} 에서는 값을 받는 쪽(반변) 후보 Animal 이 나온다. 고르기는 받는 쪽이 기본으로 이기지만, 내주는 쪽이 더 좁으면(하위 타입이면) 더 정확한 내주는 쪽이 이긴다. Dog 는 Animal 의 하위 타입이라 TRow = Dog.

TRow 를 두 번째로 쓰는 prop 은 NoInfer 로 줍기에서 뺀다 (TS 5.4+).

type Props<TRow> = {
  rows: TRow[]
  renderRow: (row: TRow, index: number) => React.ReactNode
  selectedRow?: NoInfer<TRow>
}

rows={[{ name: ‘lee’ }]} selectedRow={{ name: ‘kim’, age: 1 }} 를 넘긴다. NoInfer 가 없으면 두 자리 모두에서 후보를 줍고, 둘 다 객체 리터럴이라 union 으로 합쳐져 TRow 가 넓어지고 에러가 나지 않는다. NoInfer 가 있으면 selectedRow 는 후보로 줍지 않아 TRow = { name: string } 이 되고, selectedRow 를 그 타입으로 검사하다 age 에서 에러가 난다.

extends Record<string, any> 는 원시값만 막는다.

Record<string, any> 는 { [key: string]: any } 로 펼쳐진다. 값이 any 인 인덱스 시그니처는 원시값만 빼고 다 받아서 type 별칭 객체·interface·배열·함수가 통과하고 원시값만 막힌다. Record<string, unknown> 은 암묵적 인덱스 시그니처가 있는 type 별칭 객체만 통과한다 — interface 는 선언 병합으로 속성이 늘 수 있어 암묵적 인덱스 시그니처를 받지 못하고, 배열·함수도 막힌다. object 는 원시값만 막는다.

추론 규칙 전체는 589.

import * as React from 'react'
import { UseComboboxProps, useCombobox } from 'downshift'

function Combobox<T extends Record<string, any>>(
  props: UseComboboxProps<T> & {
    renderItem: (item: T) => React.ReactNode
  }
) {
  const combobox = useCombobox(props)

  return (
    <div>
      <input
        placeholder="구성원을 검색해주세요."
        {...combobox.getInputProps()}
      />
      <div {...combobox.getMenuProps()}>
        {combobox.isOpen &&
          props.items.map((item, index) => (
            <div key={item.id} {...combobox.getItemProps({ item, index })}>
              {props.renderItem(item)}
            </div>
          ))}
      </div>
    </div>
  )
}

function App() {
  return (
    <Combobox
      items={[{ id: 1, name: 'eunsoo' }]}
      itemToString={(item) => `${item?.id}`}
      renderItem={(item) => {
        return <div>{item.name}</div>
      }}
      onInputValueChange={({ inputValue }) => {
        console.log(inputValue)
      }}
    />
  )
}
#480

명시도가 같으면 뒤에 온 규칙이 이긴다. /b 로 바로 들어오면 head 에 vendor.css(.btn 회색) 다음 shared.css(.btn 파랑)가 와서 파랑이 이긴다. /a 에서 /b 로 이동하면 shared.css 는 /a 가 이미 불러와 앞에 그대로 있고 vendor.css 만 뒤에 붙어 회색이 이긴다. 같은 두 시트인데 순서만 다르다. 순서를 바꾸는 곳은 셋이다. 1 주입: 이미 있는 href 는 건너뛰고 없는 시트만 추가하며 App Router 는 시트를 지우지 않는다. 2 빌드 청킹: 14.2.0 의 새 CSS 청킹(#63157)이 여러 페이지의 CSS 를 한 청크로 병합한다. global CSS 는 병합하지 않게 고친 게 #67373(v15), CSS Modules 의 sideEffects 를 true 로 고정한 문제를 고치는 #70087 은 열려 있다. 3 Pages Router 의 반대 증상: 이동 뒤 서버 렌더 시트(data-n-p)를 지우는데 동적 import CSS 는 이미 있어 건너뛰었으므로 아예 사라진다. #72959 가 동적 CSS 에 data-n-p 를 붙이지 않게 고쳤다.


#479
export function parseNativeEmoji(unified: string): string {
  return unified
    .split('-')
    .map((hex) => String.fromCodePoint(parseInt(hex, 16)))
    .join('')
}
  • unified 문자열을 -로 분리하여 개별 유니코드 코드 포인트를 얻는다.
  • 각 16진수 코드 포인트를 정수로 변환하고, 그에 해당하는 캐릭터를 반환한다.
  • 최종적으로 변환된 캐릭터들을 연결하여 하나의 문자열로 생성한다.

#477
const emptyCsvFile = new File([''], 'default.csv', { type: 'text/csv' })
const emptyPngFile = new File([''], 'default.png', { type: 'image/png' })

formData.append('product_coupons', emptyCsvFile)
formData.append('product_imgs', emptyPngFile)

console.assert(emptyCsvFile.size === 0)

빈 파일을 FormData에 첨부하여 전송하는 방법

  • File 객체를 사용하여 빈 파일을 생성할 수 있다.
  • 파일의 MIME 타입을 지정하여 타입에 맞는 빈 파일을 만들 수 있다.
  • 생성된 빈 파일을 FormData 객체에 추가하여 전송할 수 있다.

이는 서버에 빈 형태의 파일을 전송할 때 유용하며, API 테스트 및 디폴트 값 설정에 활용될 수 있다.

#461
33 중 9페이지