Renomeamos a Slash como parte da nossa Captação de recursos da Série B, e queríamos renovar visualmente nosso produto junto com nossa marca. Rapidamente percebemos que, além de atualizar nossa interface do usuário, essa era uma ótima oportunidade para resolver grande parte da dívida técnica que acumulamos nos últimos três anos de crescimento extremamente rápido.

Assim, projeto Lifting facial nasceu - uma oportunidade para estabelecermos as bases para o front-end da Slash para o nosso negócio e equipe em expansão. Queríamos tomar decisões que resistissem ao teste do tempo, aprendendo com os erros do passado.

Parte 1: Lidando com a dívida técnica

Nesta seção, abordarei cada ponto relacionado à “dívida”. Mais adiante, na parte 2, aprofundarei nossas soluções para cada ponto.

Emoção: gargalos de tempo de execução do CSS no JS

Nosso sistema de design original desenvolvido com base em emoção e nos serviu bem. A principal vantagem de uma biblioteca CSS em JS era a co-localização da marcação e do estilo de um componente, o que tornava a iteração extremamente rápida. No entanto, à medida que nossos clientes cresciam e tinham um volume de dados cada vez maior, começamos a observar gargalos de desempenho no Emotion. O CSS em JS requer sobrecarga de tempo de execução e pode tornar o loop de eventos principal mais lento. Isso é perceptível ao virtualizar listas grandes e rolar rapidamente. Quando os componentes são montados e desmontados rapidamente, o cálculo dinâmico de estilos e a geração de folhas de estilo afetam o navegador a ponto de ele não conseguir renderizar a 60 fps. Outras empresas têm encontrou gargalos de desempenho semelhantes e afastou-se das soluções dependentes da sobrecarga de tempo de execução.

“Componentes divinos” baseados em adereços

O conceito original de “Objeções de Deus” tem origem na OOP, onde um objeto assumiria demasiadas responsabilidades sozinho. Observamos situações semelhantes nos nossos componentes React, aos quais começamos a chamar de “componentes divinos”.

Muitos dos nossos componentes originais do React foram escritos com uma interface baseada em props, na qual você fornece dados e callbacks e renderiza um único componente. À primeira vista, isso proporciona uma interface elegante: basta fornecer dados e props e confiar que o componente cuidará de toda a marcação e lógica de negócios.

Na prática, à medida que a base de código cresce, esses componentes são ampliados para cobrir usos que não foram originalmente considerados ou pretendidos. Cada vez que um engenheiro precisava de uma nova configuração, ele adicionava props, tornando a manutenção e o uso mais difíceis com o tempo. Esses componentes acabaram se tornando “componentes divinos” que precisavam de cuidados para serem mantidos, com props únicos destinados a resolver certos problemas ou “saídas de emergência” destinadas a injetar/substituir marcações adicionais no próprio componente (semelhante a um padrão de render prop). Mostrarei exemplos de código mais adiante no blog.

Gerenciamento inconsistente de formulários

Nossa solução original de gerenciamento de formulários era simplesmente o gerenciamento de estado do React. O estado local é fácil de usar, mas carece de padronização e segurança de tipo profunda, o que levava a uma qualidade inconsistente do código. A maneira como cada engenheiro implementava o estado local era um pouco diferente — alguns aproveitavam o API de contexto com um grande estado de formulário compartilhado, enquanto outros dividiriam os campos em individuais useState chamadas.

Quando chegou a hora de estender esses formulários, muitas vezes metadados como campoTocado seria adicionado, criando um componente confuso com muitos estados locais explicitamente definidos para lidar com interações de formulário que deveriam ser padronizadas.

Parte 2: Execução

Agora que descrevemos as dificuldades atuais da nossa base de código, vou me aprofundar nas soluções e na tomada de decisões por trás de cada uma delas.

Migração para o Tailwind v4.0

Ao avaliar as soluções de estilo, tivemos dois finalistas: Tailwind v4.0 e PandaCSS, pois ambos têm zero sobrecarga de tempo de execução e permitem a co-localização da marcação e do estilo de um componente. As vantagens do PandaCSS eram a segurança de tipos e uma curva de aprendizado baixa, já que nosso sistema de design existente era muito semelhante.

As vantagens do Tailwind eram um ecossistema maduro e a familiaridade dos desenvolvedores. Muitos de nossos engenheiros já haviam usado o Tailwind em projetos pessoais e gostavam bastante dele, inclusive eu. Existem ferramentas fantásticas com suporte integrado ao Tailwind, como Supernova e Variantes TailwindAs ferramentas de codificação de IA também são excelentes para escrever nomes de classes Tailwind prontos para uso, tornando a migração muito menos trabalhosa.

Para visualizar as vantagens de desempenho de não ter sobrecarga de tempo de execução, aqui estão os gráficos de chama antes e depois da mesma operação (rolagem rápida em uma visualização de tabela virtualizada grande), no Emotion e no Tailwind:

A Emotion introduz uma sobrecarga de injeção de estilo em tempo de execução
O Tailwind não sofre com esses gargalos!

Pipeline do Figma para o código

Ao migrar para o Tailwind, queríamos uma maneira de manter nossos tokens e código-fonte do Figma sincronizados, com o Figma sendo nossa fonte de verdade. Nosso sistema de design no Figma é mantido por nossa agência de design, Metacarbonato, que toma as decisões de design e cria a folha de estilo com tokens semânticos.

Nosso objetivo era ter um pipeline em que quaisquer alterações no sistema de design subjacente pudessem ser revisadas manualmente e implementadas com o mínimo de esforço. Escolhemos a Supernova para extrair tokens de design do nosso Figma e transformá-los em código, pois eles têm um excelente extrator de código Tailwind v4.0.

Ao trabalhar com nossa saída CSS, encontramos um problema interessante: Queríamos que os tokens semânticos apontassem para valores diferentes, dependendo da propriedade à qual se aplicavam. Aqui está um exemplo ilustrativo:

/* Raw Supernova output */
@theme {
	--color-bg-neutral-subtle-default: var(--color-light-product-neutral-100);
	--color-border-neutral-subtle-default: var(--color-light-product-neutral-500);
	--color-text-neutral-subtle-default: var(--color-light-product-neutral-1000);
}

Queremos neutro-sutil-padrão para resolver em 3 tons diferentes de neutro em relação aos produtos leves: 100, 500 e 1000 - no entanto, se usássemos apenas isso, o Tailwind v4.0 --cor variável de tema geraria bg-bg-neutro-sutil-padrão, fronteira-bg-neutro-sutil-padrão, bg-texto-neutro-sutil-padrão e muitos outros nomes de classe que não fazem sentido e são redundantes na nomenclatura.

Assim, escrevemos um script de compilação personalizado para transformar nossa saída e utilizar o Tailwind v4.0 mais específico. espaço de nomes das variáveis do tema:

/* Build script output */
@theme {
	--background-color-neutral-subtle-default: var(--color-light-product-neutral-100);
  --border-color-neutral-subtle-default: var(--color-light-product-neutral-500);
  --text-color-neutral-subtle-default: var(--color-light-product-neutral-1000);
  
  /* Additional properties that we want to have the same values as other tokens */
  --divide-color-neutral-subtle-default: var(--color-light-product-neutral-500);
}

O que resulta em nomes de classe como bg-neutro-sutil-padrão, fronteira-neutra-sutil-padrão e texto neutro sutil padrão - que se resolvem em seus distintos tons.

Assim, à medida que nosso sistema de design subjacente muda, basta executar nosso exportador Supernova e colocar a saída em nossa base de código para revisão. Em seguida, nosso script de compilação converte automaticamente a saída bruta em um arquivo CSS compilado que todos os nossos pacotes consomem.

Good to know

At Slash, we follow a philosophy of maintaining control over our tooling . This allows us to extend functionality quickly and prevent vendor lock in. It also helps us diagnose and fix issues directly. By having our build script, we aren’t locked into Supernova as a vendor, and can extend it - like adding support for divide to be the same color as border, or light vs dark mode themes.

Other examples include our query builder and JSON schema to typescript code generation pipeline.

Bibliotecas de interface do usuário sem cabeçalho: Base UI e React Aria

Em primeiro lugar, gostaria de explicar por que queríamos uma biblioteca de interface do usuário sem cabeçalho, e não uma solução estilizada. O objetivo da reformulação é que nossa identidade de marca seja expressa claramente por meio de nosso produto — e usar uma solução pré-estilizada como Shadcn ou MaterialUI iria contra esse objetivo, pois pré-estilizaria nossos componentes.

Antes da reformulação, usávamos a Radix UI, mas não estávamos certo sobre o seu futuro, à medida que vimos os principais mantenedores migrarem para o Base UI. O Base UI surgiu como uma biblioteca simples e extensível que se adapta muito bem às nossas necessidades, com grande aderência à acessibilidade. Dados seus excelentes mantenedores, o Base UI nos dá confiança no futuro de que ele crescerá junto com as necessidades do Slash.

Para determinados componentes que ainda não têm suporte para a interface de usuário Base (por exemplo, nosso seletor de datas), optamos pelo React Aria, que foi o segundo colocado em nossa busca por uma interface de usuário headless. O React Aria foi testado em batalha e segue rigorosamente todos os padrões de acessibilidade. No final das contas, criamos nossos componentes com base na Base UI, já que a abordagem do React Aria é bastante opinativa e exige que a gente se comprometa totalmente com o ecossistema React Aria, e a gente preferiu algo com menos sobrecarga mental.

Componentes compostos

Como mencionado anteriormente, os componentes baseados em props são inicialmente elegantes, mas sofrem com a baixa extensibilidade. Nossa solução para criar componentes mais extensíveis foi apoiar-nos fortemente no arquitetura de componentes compostos, que transfere o controle de marcação de um componente para seu pai. A melhor maneira de ilustrar a diferença entre os dois é com um exemplo — aqui está uma versão simplificada do nosso componente de seleção pesquisável antigo e novo:

// Old SearchableSelect implememtation
type Props<T> = {
  selection?: string;
  setSelection?: (val: string) => void;
  
  // Escape hatches added over time
  shouldSetIsChangedFromTextRefAfterTableViewUpdate?: boolean;
  customTextSelectionLogic?: boolean;
  preloadOptions?: boolean;
  hideDropdownOnEmptyOptions?: boolean;
  renderSelectedItem?: (item: T) => ReactNode;
  hideRightIcon?: boolean;
  isClearable?: boolean;
  
  // ... more props
};

export function SearchableSingleSelect<T>(props: Props<T>) {
  const {
    customTextSelectionLogic = false,
    preloadOptions = false,
    hideDropdownOnEmptyOptions = false,
    renderSelectedItem,
    hideRightIcon = false,
    isClearable = true,
    shouldSetIsChangedFromTextRefAfterTableViewUpdate = true,
    // ... more props
  } = props;

  return (
    <FormElementWrapper
      renderInput={(props) => (
        <MenuBase
          setSelection={(value) => {
            setSelection(value);
            
            // Escape hatch for custom behavior
            if (customTextSelectionLogic) {
              return;
            }
            
            // Different logic based on renderSelectedItem prop
            if (value && !renderSelectedItem) {
              setTextInput(findLabel(value));
            } else if (value && renderSelectedItem) {
              setTextInput('');
            }
          }}
        >
          {/* Conditional rendering based on prop */}
          {renderSelectedItem && selectedItem ? (
            <CustomRenderedItem item={selectedItem} />
          ) : null}
          
          <TextInput value={textInput} onChange={...} />
        </MenuBase>
      )}
      {/* Nested ternaries based on multiple props */}
      {...(hideRightIcon ? {} : {
        rightIcon: () => {
          return !isClearable ? (
            <ChevronIcon />
          ) : textInput?.length || (renderSelectedItem && selectedItem) ? (
            <ClearButton onClick={clearSelection} />
          ) : (
            <ChevronIcon onClick={toggleDropdown} />
          );
        }
      })}
    />
  );
}
// Old SearchableSelect usage
<SearchableSingleSelect
  selection={selection}
  setSelection={setSelection}
  tableManager={tableManager}
  isClearable={true}
  customTextSelectionLogic={false}
  hideDropdownOnEmptyOptions={false}
  renderSelectedItem={(item) => <CustomView item={item} />}
  shouldSetIsChangedFromTextRefAfterTableViewUpdate={true}
  // ... more props
/>

E aqui está nosso novo componente selecionável pesquisável, que usa uma arquitetura de componentes compostos:

// New SearchableSelect implementation

// Context manages shared state
const SearchableSelectContext = createContext<{
  selectState: SingleSelectState;
  tableManager: TableManagerState;
}>(null);

// Root sets up context
const Root = ({ children, tableManager, type }) => {
  const selectState = useSingleSelectState();
  
  return (
    <SearchableSelectContext.Provider value={{ selectState, tableManager }}>
      <MenuV2.Root>{children}</MenuV2.Root>
    </SearchableSelectContext.Provider>
  );
};

// EachItem exposes items with callbacks
const EachItem = ({ children }) => {
  const { selectState, tableManager } = useSearchableSelectContext();
  
  const items = tableManager.data.map((item, index) => ({
    item,
    callbacks: {
      onClick: (item) => selectState.toggleItem(item.key, index),
      getIsSelected: (item) => selectState.isItemSelected(item.key),
    },
  }));

  return <>{items.map(children)}</>;
};

// SearchBar handles its own state
const SearchBar = () => {
  const { tableManager } = useSearchableSelectContext();
  const [textInput, setTextInput] = useState('');

  useEffect(() => {
    tableManager.updateSearch(textInput);
  }, [textInput]);

  return <InputV2.Field value={textInput} onChange={e => setTextInput(e.target.value)} />;
};

// ... Other components: Content, DefaultTrigger, Item
// New SearchableSelect usage
<SearchableSelect.Root type="single" tableManager={tableManager}>
  <SearchableSelect.DefaultTrigger>
    Select an option...
  </SearchableSelect.DefaultTrigger>
  
  <SearchableSelect.Content>
    <SearchableSelect.SearchBar />
    
    <SearchableSelect.EachItem>
      {({ item, callbacks }) => (
        <MenuV2.Item onClick={() => callbacks.onClick(item)}>
          <CustomView item={item} />
        </MenuV2.Item>
      )}
    </SearchableSelect.EachItem>
  </SearchableSelect.Content>
</SearchableSelect.Root>

À primeira vista, os componentes compostos são mais complicados — o desenvolvedor precisa conhecer o padrão de marcação e segui-lo. A vantagem é a flexibilidade para estender a marcação, já que o controle total fica com o pai. Assim, os hacks de marcação nunca chegam ao componente principal. Se um engenheiro precisa adicionar um rodapé ao select? Basta adicioná-lo à marcação — sem necessidade de um renderizarSeleçãoRodapé prop para o componente principal! Se um rodapé se tornar um requisito comum, crie um <SearchableSelect.Footer /> componente!

Outra armadilha comum dos componentes compostos é que os engenheiros podem duplicar implementações de padrões comuns que não estão no componente principal. Assim, nossa equipe precisa estar mais atenta aos padrões comuns, adicionando-os ao componente principal quando necessário. Você também pode usar componentes compostos como API subjacente para um componente de uma linha, mas fazemos isso de forma muito intencional para não transformar ESSES componentes em componentes divinos. Abordo mais detalhadamente como lidamos com o compartilhamento de conhecimento por meio do Storybook em uma seção posterior.

Variantes Tailwind

Ao estilizar nossos componentes, contamos muito com Variantes TailwindAlguns de nós já usamos Autoridade de Variação de Classe (CVA) e queríamos esse tipo de lógica variante em nossa solução de estilo. O Tailwind Variants tinha um algumas funcionalidades que nos levaram a utilizá-lo, sendo as principais a API de slots e a resolução de conflitos integrada.

No final das contas, precisávamos apenas de uma solução que mantivesse nosso código de variantes de estilo organizado e fosse extensível o suficiente para cobrir nossa abordagem de componentes compostos. O uso das variantes do Tailwind padronizou significativamente nosso antigo código de estilo condicional, que aplicava explicitamente diferentes tokens ao div com estilo emocional com base em suas propriedades.

Abaixo está um exemplo ilustrativo da implementação e utilização do nosso botão:

import { tv } from 'tailwind-variants';

const buttonVariants = tv({
  // Slots API - different parts of the button component
  slots: {
    base: [
      'inline-flex',
      'items-center',
      'justify-center',
      'gap-1',
      'group',
    ],
    hoverOverlay: [
      'opacity-0',
      'absolute',
      'inset-0',
      'group-hover:group-enabled:opacity-100',
      'pointer-events-none',
    ],
  },

  // Variants - different visual options.
  variants: {
    color: {
      cta: {
        base: [
          'border',
          'border-neutral-boldest-default',
          'bg-button-cta-default',
          'shadow-cta-neutral-default',
          
          'hover:border-neutral-bolder-hover',
          'hover:bg-button-cta-hover',
          'hover:text-neutral-bolder-hover',
        ],
      },
      ghost: {
        base: [
          'bg-transparent',
          'text-neutral-normal-default',
          
          'hover:bg-neutral-subtle-hover',
          'hover:text-neutral-normal-hover',
        ],
      },
    },
    size: {
      sm: {
        base: [
          'h-6',
          'px-1.5',
          'typography-button-small',
          'rounded-border-radius-sm',
        ],
      },
      md: {
        base: [
          'h-8',
          'px-2',
          'typography-button-medium',
          'rounded-border-radius-md',
        ],
      },
    },
  },

  // Compound Variants - styles when multiple variants are combined
  compoundVariants: [
    {
      // Ghost buttons with small size get reduced padding
      color: 'ghost',
      size: 'sm',
      class: {
        base: 'px-1',
      },
    },
    {
      // CTA buttons at large size get enhanced shadows
      color: 'cta',
      size: 'lg',
      class: {
        base: 'shadow-cta-neutral-prominent',
      },
    },
  ],

  // Default Variants - applied when no variant is specified
  defaultVariants: {
    color: 'neutral',
    size: 'md',
    disabled: false,
  },
});

// Extract variant props type from buttonVariants
type ButtonVariantProps = VariantProps<typeof buttonVariants>;

// Button component props
interface ButtonProps extends ButtonVariantProps {
  children?: React.ReactNode;
  onClick?: () => void;
  as?: ElementType;
  className?: string;
}

// Button component implementation
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ 
    children, 
    color, 
    size, 
    disabled, 
    onClick, 
    as: Component = 'button',
    className,
    ...rest 
  }, ref) => {
    // Generate slot classes based on variant props
    const slots = buttonVariants({ color, size, disabled });
    
    return (
      <Component
        ref={ref}
        className={slots.base({ className })}
        onClick={onClick}
        disabled={disabled}
        {...rest}
      >
        {/* Hover overlay slot */}
        <span className={slots.hoverOverlay()} />
        
        {/* Button content */}
        {children}
      </Component>
    );
  }
);
// End usage example
<Button color="cta" size="lg">
  Large CTA Button
</Button>

Como você pode ver, quem faz a chamada de Botão não precisa realmente usar botãoVariantes - passa props para Botão como opções de configuração, que são então utilizadas para chamar botãoVariantes internamente. Também utilizamos o estender Recurso para compartilhar estilos entre componentes semelhantes, como campos de entrada e campos de seleção.

Você também perceberá como é fácil analisar os estilos devido aos nossos nomes de classe semânticos.

Testes e documentação: Storybook + Chromatic

Na primeira metade do projeto, trabalhei sozinho e não dei a devida importância aos testes e à documentação. No entanto, à medida que fui concluindo a biblioteca de componentes básicos e mais engenheiros se juntaram para renovar seus respectivos produtos, ficou mais evidente que precisávamos de um local centralizado para responder a perguntas muito simples, como “temos esse componente?” e “como uso esse componente corretamente?”.

Um dos nossos novos engenheiros, Sam, configurou sozinho nosso conjunto de testes Storybook e Chromatic. Sem eles, poderíamos ter caído rapidamente nas mesmas armadilhas da dívida técnica do uso indevido de componentes, especialmente considerando a curva de aprendizado dos componentes compostos.

O Storybook nos permite centralizar exemplos de uso para todos os componentes, permitindo que essas perguntas sejam respondidas na íntegra com exemplos de código. Também fica imediatamente claro qual componente um engenheiro deve usar e se ele suporta ou não as opções de configuração de que precisa.

O Chromatic detecta regressões visuais, de modo que quaisquer alterações nos componentes subjacentes serão sinalizadas imediatamente se alterarem qualquer elemento visual, bloqueando a fusão até que sejam revisadas manualmente.

Gerenciamento de formulários: Zod vs ArkType

A base de código do Slash é escrita inteiramente em Typescript, e aproveitamos bastante esse fato gerando tipos compartilhados em toda a nossa base de código no momento da compilação. Queríamos uma solução de gerenciamento de formulários que dificultasse aos desenvolvedores cometerem erros e aproveitássemos nossa ampla segurança de tipos.

Acabamos escolhendo o ArkType, pois ele era tão fortemente acoplado ao Typescript que podíamos evitar qualquer desvio. Podíamos pegar nossos tipos gerados e acoplá-los um a um com os tipos do ArkType usando satisfaz, de modo que, se nosso tipo subjacente mudar, nossa base de código lançará erros de tipo.

Aqui está um exemplo ilustrativo:

# models/entity/Address.yaml
title: Address
type: object
properties:
  addressLine:
    type: string
  addressLine2:
    type: string
  addressCity:
    type: string
  addressState:
    type: string
  addressCountry:
    type: string
    minLength: 2
    maxLength: 2
    description: ISO 3166-1 Alpha-2 Country Code (e.g., US, FR, UK, DE, ...).
required:
  - addressLine
  - addressCity
  - addressState
// shemas/entity/Address.ts

// Schema implementation of an address model
import type { entity } from '@slashfi/models';
import { type Type, type } from 'arktype';

// Helper to configure default error messages
export const nonEmptyString = (fieldName: string) =>
  type('string>0').configure({
    message: `${fieldName} is required.`,
  });

export const Address = type({
  addressLine: nonEmptyString('Address Line'),
  addressLine2: 'string | undefined',
  addressCity: nonEmptyString('Address City'),
  addressState: nonEmptyString('Address State'),
  addressCountry: type('string==2 | undefined').configure({
    message: 'Country code must be a 2-letter ISO 3166-1 Alpha-2 code',
  }),
}) satisfies Type<entity.Address>;
// entity.Address is built automatically from our YAML spec

export const defaultAddress: typeof Address.infer = { ... }
// Illustrative form component that submits an address - using Tanstack form
const AddressForm = () => {
  const form = useAppForm({
    defaultValues: defaultAddress,
    // Validate the form input on mount and on change
    validators: {
      onMount: Address,
      onChange: Address,
    },
    onSubmit: ({ value }: { value: Address }) => {
      // Submit the value
      console.log(value);
    },
  });

  return (
    <form.AppForm>
      <form.Field name="addressLine">
        {(field) => (
          <>
            <Input
              value={field.state.value}
              onChange={(e) => field.handleChange(e.target.value)}
            />
            {/* Use built in metadata like isTouched */}
            {field.state.meta.isTouched ? (
              <p>{getMessage(field.state.meta.errors)}</p>
            ) : null}
          </>
        )}
      </form.Field>

      {/* ... more inputs for each field in address */}

      {/* Subscribe for reactive form update */}
      <form.Subscribe
        selector={({ canSubmit, isSubmitting }) => ({
          canSubmit,
          isSubmitting,
        })}
      >
        {({ canSubmit, isSubmitting }) => (
          <Button loading={isSubmitting} disabled={canSubmit}>
            Create vendor
          </Button>
        )}
      </form.Subscribe>
    </form.AppForm>
  );
};

Embora o Zod e o React Hook Form pudessem ter nos servido bem, queríamos algo que aproveitasse diretamente o TypeScript, e o ArkType era claramente o líder nesse aspecto. Escolhemos o Tanstack Form em vez do React Hook Form devido à sua adesão mais rigorosa ao TypeScript.

Uma coisa que você notará com o formulário Tanstack é como ele o obriga a seguir uma estrutura rígida e padronizada. Embora isso exija um certo aprendizado, achamos que vale a pena para garantir a padronização na forma como escrevemos formulários em nossa base de código.

Implementação: sinalizador de recurso e ramificação de código

Quando chegou a hora de escrever o código para o Facelift, queríamos evitar um PR enorme, já que ainda estávamos lançando novos recursos no sistema de design antigo junto com o facelift.

Utilizamos um sinalizador de recursoe simplesmente habilitou o sinalizador de recurso para apenas nossa conta, permitindo-nos ração para cães nossas próprias alterações. Também implementamos uma barra de ferramentas simples para ativar e desativar o facelift, tornando extremamente fácil identificar regressões ao ativar e desativar rapidamente o facelift.

O botão que usaríamos para ativar e desativar rapidamente o facelift para detectar regressões

Quanto à ramificação do código propriamente dita, nosso objetivo era não duplicar o código da lógica de negócios, fazendo o seguinte:

const CardsPage = () => {
  const { isFacelift } = useFaceliftToolbar();
  
	// Existing business logic
	const cards = useCards()
  
  if (isFacelift) {
	  // New, facelifted components
	  return (
		  <CardsTableV2 cards={cards} />
	  )
  }

	
	// Existing UI
	return (
		<CardsTable cards={cards} />
	)
}

O exemplo acima ilustra um caso ideal. - a realidade de introduzir grandes quantidades de código novo em uma base de código existente é muito mais complicada. Por exemplo: se você executar o éFacelift alterar um nível superior (na página inicial, por exemplo), você precisa duplicar toda a lógica de negócios para todos os ramos abaixo desse componente. Mas se você realizar a verificação no nível do componente individual, ficará preso à lógica de marcação antiga do pai, que muitas vezes precisa de uma reformulação.

O que nos protegeu de problemas de produção foi ocultar toda essa funcionalidade por trás de um sinalizador de recurso, de modo que, para todos os clientes reais, éFacelift sempre foi falso até estarmos prontos. Internamente, manteríamos isso ativado para que pudéssemos detectar regressões à medida que elas chegassem à plataforma.

À medida que a reformulação ficou completa, fizemos parcerias com clientes com os quais tínhamos um relacionamento próximo e disponibilizamos a opção de desativá-la, solicitando feedback e identificando regressões sem bloquear nenhum fluxo de trabalho existente.

Notas sobre a execução

Embora a seção acima esteja organizada de forma muito ordenada, essa não foi, de forma alguma, a ordem de implementação. Na realidade, essas decisões foram tomadas por meio de tentativas e iterações rápidas. Por exemplo,

  • Meu primeiro rascunho do nosso script de compilação CSS encheu de spam o @utilidade diretiva para criar cada nome de classe personalizado que queríamos - então Sam desenvolveu a partir disso, encontrando isto discussão no GitHub que descreveu os espaços de nomes variáveis do tema tailwind.
  • Houve duas iterações do nosso componente de seleção única pesquisável usando diferentes componentes BaseUI antes de decidirmos pela nossa abordagem atual, usando o API do menu.

Tudo isso para dizer que não tomamos essas decisões de forma tão elegante quanto as compensações descritas acima. O traço comum é a iteração constante, sem a paralisia de sentir que você precisa acertar na primeira tentativa.

Parte 3: Aprendizados

Eu, pessoalmente, aprendi muito com um projeto cujo escopo abrangia todo o front-end. Nossa equipe também aprendeu muito, especialmente à medida que mais engenheiros contribuíram para renovar seus respectivos produtos.

“Elevando o piso”

Uma das principais razões pelas quais a dívida técnica se acumulou, em primeiro lugar, foi a falta de compartilhamento de conhecimento. Em uma startup jovem, isso não é um problema, pois todos trabalham em estreita colaboração. Mas, à medida que a equipe cresce e a área de atuação do produto se expande, torna-se impossível compartilhar suposições e padrões de uso tão rapidamente quanto antes.

Nossa filosofia para a reformulação foi “elevar o nível”. Queremos dificultar a escrita de código front-end ruim, o que significa documentar como é um bom código front-end e detectar erros antes que eles cheguem à produção. O Storybook e o Chromatic são essenciais para isso e valem bem o investimento para serem configurados. Cada esforço que você dedicar para elevar o nível terá retornos compostos na qualidade do código em toda a base de código — e o inverso também é verdadeiro.

A realidade dos projetos longos

A reformulação do início ao fim levou cerca de quatro meses de trabalho contínuo, com uma “semana de hack” dedicada no final, que durou três semanas e envolveu outros seis engenheiros que contribuíram para a reformulação (muito amor para eles <3). Também tive um excelente gerente de projetos (Andy), que ajudou a definir o escopo de todo o projeto, comunicar-se com a Metacarbon e fornecer feedback sobre novos padrões de experiência do usuário.

Como a reformulação não era um projeto que eu pudesse lançar em partes, não tive a alegria de lançar o produto para os clientes por um longo tempo. Aprendi que uma boa abordagem é alternar o contexto internamente — às vezes, eu trabalhava na biblioteca de componentes principais e, em seguida, aplicava esses componentes principais a algumas páginas completas, alternando entre as duas tarefas. Essa abordagem também ajuda a detectar rapidamente casos extremos no uso dos componentes principais, forçando você a voltar e traçar melhores linhas de abstração.

Pode ser desmoralizante refatorar toneladas de código sem receber nenhum reconhecimento dos clientes. O que me manteve motivado foram algumas coisas:

  1. Ir trabalhar continuava sendo divertido todos os dias por causa das pessoas da Slash!
  2. Eu ainda poderia mostrar o progresso internamente. Receber uma espécie de dopamina ao mostrar a alguém o progresso que você está fazendo é uma ótima maneira de se manter motivado, especialmente se você ama o que faz.
  3. Este projeto iria estabelecer as bases para nossa base de código front-end por um longo tempo, esperamos, então valia a pena fazê-lo da maneira certa.
  4. Slash está crescendo e vencendo, o que era o GERAL motivação.

Parte 4: O que vem a seguir

Com o lançamento da nossa reformulação em andamento, nossa principal prioridade passa a ser detectar casos extremos e regressões. Felizmente, contamos com uma equipe de suporte de nível internacional que auxilia nossos clientes nessa transição e identifica problemas com rapidez.

Se você quiser conferir nosso trabalho, pode vê-lo em nosso site de demonstração, onde você também pode conferir os recursos que o Slash oferece!

Se você chegou até aqui e é um engenheiro talentoso que deseja resolver problemas para clientes reais em uma startup em rápido crescimento, inscreva-se. aquiAdoraríamos receber seu contato.

Read more from us