Pitch: NanoStore - Meu primeiro, pequeno e ambicioso pacote NPM (TypeScript)

Olá, pessoal! Venho apresentar o NanoStore, meu primeiro, pequeno (com apenas 129 linhas de código) e ambicioso (uma alternativa simples ao ReduxJS) pacote NPM. O NanoStore é uma alternativa para gerenciamento de estado global em React, que utiliza somente APIs nativas do React (ContextAPI e o hook useRedux), ele tem tipagem forte e inferência inteligente. O objetivo principal do NanoStore é simplificar o processo para o desenvolvedor, 100% da energia gasta na criação dele foi focado em experiência de uso.

OBS.: Caso você seja um iniciante em TypeScript, este pacote foi feito pensando principalmente em você, toda a complexidade de tipagem (e foram muitas) está sob o capô do pacote, para que você faça um código mais próximo do JS do que do TS.

Sem mais delongas, vou demonstrar como ele funciona. Para o exemplo a seguir vou iniciar um projeto react com vite (até para avaliar o uso), mas o desenvolvimento do pacote foi no NextJS então pode ser configurado exatamente igual.

npm create vite@latest nano-store-example -- --template react-ts
npm install
npm run dev
npm install
npm install @jeff.carlosbd/nano-store

Projeto criado e pacote instalado, agora vamos criar nosso store, começando pelo arquivo state.ts. É onde vamos deixar nosso estado inicial. Caso tenha uma pasta src, recomendo a criar na pasta src/store/state.ts ou na raiz do projeto (caso não tenha uma pasta src) store/state.ts:

// src/store/state.ts

import { initState } from "@jeff.carlosbd/nano-store";

interface InitialState {
    pokemons: string[]
}

const initialState: InitialState = {
    pokemons: []
}

export const { createReducer, mountStore } = initState(initialState)

Basicamente criamos uma interface para definir a estrutura do nosso estado e logo depois uma variável que armazenará um ojeto que atende esta interface. Por fim iniciamos o nosso estado com a função initState() do pacote, esta retorna duas funções que vamos usar para dar continuidade.

Agora vamos iniciar a montagem da nossa store num arquivo separado (isso é importante para evitar conflitos de imports) src/store/index.ts:

// src/store/index.ts

import { mountStore } from "./state";

export const { StoreProvider, useStoreActions, useStoreSelects } = mountStore(
  [],
  [],
  []
);

Utilizando a função mountStore que foi exportada pelo nosso src/store/state.ts, montamos nosso store que retornou mais 3 itens que vai nos auxiliar em outras etapas, por enquanto os 3 arrays que mountStore precisa vamos utilizar arrays vazios, mas logo abaixo vamos atualizar isso.

Agora vamos usar nosso StoreProvider no nosso src/main.tsx ( src/app/layout.tsx ou app/layout.tsx do NextJS) para envolver nossa aplicação com nosso provider, dessa forma:

// src/main.tsx

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import { StoreProvider } from './store/index.ts'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <StoreProvider>
      <App />
    </StoreProvider>
  </StrictMode>,
)

Agora vamos dar uma limpada no src/App.tsx ficando dessa forma:

import './App.css'

function App() {
  return (
    <h1>NanoStore - Example</h1>
  )
}

export default App

Nesse ponto já temos nossa store configurada e pronta para uso. Para começar, vamos fazer um teste de busca de dados, vamos alterar nosso src/store/state.ts para conter uma informação inicial e vamos recuperar ela no nosso src/App.tsx:

// src/store/state.ts

// ...
const initialState: InitialState = {
  pokemons: ["bulbasaur", "ivysaur"],
};
// ...

Agora no nosso src/App.tsx vamos usar o hook useStoreSelects para recuperar os dados da nossa store. Este hook recebe uma função que tem nosso state como parâmetro e que retorna o retorno da função (o que vc retornar na função, é o que o hook te retorna):

const pokemons = useStoreSelects((state) => state.pokemons)

Agora vamos exibir esse array de strings:

return (
  <ul>
    {pokemons.map((pkm) => <li key={pkm}>{pkm}</li>)}
  </ul>
)

src/App.tsx completo:

// src/App.tsx

import './App.css'
import { useStoreSelects } from './store'

function App() {
  const pokemons = useStoreSelects((state) => state.pokemons)

  return (
    <ul>
      {pokemons.map((pkm) => <li key={pkm}>{pkm}</li>)}
    </ul>
  )
}

export default App

Legal, estamos recebendo e exibindo os dados, mas agora vamos criar uma função que altera esse estado, vamos começar zerando nosso estado inicial novamente:

// src/store/state.ts

// ...
const initialState: InitialState = {
  pokemons: [],
};
// ...

Agora precisamos criar "reducers" que vão manipular nosso estado, para que fique organizado recomendo criar uma pasta de reducers src/store/reducers/ e criar arquivos para separar os reducers por assunto como por exemplo src/store/reducers/pokemons.ts que será onde ficará nossos reducers que alteram o estado do array de pokemons, com isso vamos ao nosso primeiro reducer que irá adicionar um pokemon no estado:

// src/store/reducers/pokemon.ts

import { createReducer } from "../state";

const addPokemon = createReducer(
  "add-pokemon",
  (store, payload: { newPokemonName: string }) => {
    store.state.pokemons.push(payload.newPokemonName);
  }
);

export const pokemonsReducers = [addPokemon];

Começamos importando a função createReducer() do nosso state.ts, essa função recebe dois parametros, o primeiro é uma string de identificação do reducer, devem ser strings únicas (atento nessa parte pois a biblioteca ainda não verifica isso), já o segundo parâmetro é a função que irá receber e manipular nosso estado, essa função recebe como primeiro parâmetro o nosso store e o segundo parâmetro é o nosso payload (informação que irá ser injetada no estado global.

⚠️ A tipagem manual do payload é muito importante, ela te auxiliará a saber o que cada reducer precisa para funcionar. Isso ficará mais nítido ao usar o reducer.

Por fim basta fazer um .push() no nosso array de pokemons que está no nosso estado. cnostruido o reducer, agora é interessante adicionar ele num array de reducers que no caso chamamos de pokemonsReducers. É interessante ter um arquivo que irá centralizar todos os nossos reducers, para isso estou utilizando o arquivo src/store/reducers/index.ts:

// src/store/reducers/index.ts

import { pokemonsReducers } from "./pokemons";

export const allReducers = [...pokemonsReducers];

Precisamos agora disponibilizar essa variável "allReducers" para nossa aplicação no arquivo src/store/index.ts, mas a nossa função createReducer cria um objeto com várias informações, então precisaremos desestruturar elas e passar adequadamente para nosso mountStore():

// src/store/index.ts

import { allReducers } from "./reducers";
import { mountStore } from "./state";

const reducers = allReducers.map((r) => r.reducer);
const actions = allReducers.map((r) => r.action);

export const { StoreProvider, useStoreActions, useStoreSelects } = mountStore(
  reducers,
  actions,
  []
);

Estamos importando a variável que contem todos os nossos "reducers" e logo após fazendo map's para extrair os reducers de fato e suas respectivas actions, ainda não falamos sobre as actions, mas ela simplesmente são informações que indicam para os hooks qual reducer deve chamar e quais parametros devem enviar, veremos no próximo código.

Agora vamos voltar ao nosso src/App.tsx para criar um input que usará esse reducer:

// src/App.tsx

import { useState } from 'react'
import './App.css'
import { useStoreActions, useStoreSelects } from './store'

function App() {
  const action = useStoreActions()
  const pokemons = useStoreSelects((state) => state.pokemons)

  const [newPokemonName, setNewPokemonName] = useState('')

  return (
    <>
      <form onSubmit={(e) => {
        e.preventDefault()
        action({ type: 'add-pokemon', payload: { newPokemonName } })
      }}>
        <label htmlFor="add-pokemon-input" style={{ display: 'block' }}>Adicionar pokemon:</label>
        <input
          id="add-pokemon-input"
          value={newPokemonName}
          onChange={(e) => setNewPokemonName(e.target.value)}
        />
      </form>
      <ul>
        {pokemons.map((pkm) => <li key={pkm}>{pkm}</li>)}
      </ul>
    </>
  )
}

export default App

Começamos importando nosso hook que nos dá a função action() ela serve para disparar uma atualização no estado, após o nosso antigo select criei um estado para controlar um input que iremos usar para adicionar itens, e para finalizar tem o HTML de um input simples que ao pressionar enter causa um submit, no caso o pedacinho que precisamos é o que acontece no onSubmit:

// src/App.tsx
// ...
    action({ type: 'add-pokemon', payload: { newPokemonName } })
// ...

Basicamente estamos usando a função action() e passando um objeto que segue a estrutura pré definida, você verá que a tipagem já está toda pronta, ao digitar o type e iniciar a string, o VSCode já trará todas as as opções de reducers criados, ao definir um type, imediatamente o VSCode já definirá como o payload deve ser estruturado seguindo a tipagem que definimos na criação do reducer.

Bom pessoal, esse é o uso básico desse pacote que recem criei, ainda existe um segundo tipo de reducer que é o reducer com efeito colateral, perfeito para criar reducers com Promises, ainda há muita melhoria nesse código e adoraria ter contribuições caso tenham interesse, muito obrigado por ler até aqui e dependendo da aceitação planejo uma parte dois demonstrando efeitos colaterais.