Pedro Sousa
‹ ← Artigos

Artigo · Blog

O @State que você jogou no View não é um problema de SwiftUI. É um problema de onde a decisão mora.

A maioria dos problemas de arquitetura em SwiftUI não começa no framework. Começa quando você decide onde o estado mora sem entender o que essa decisão vai custar em seis meses. Isso não é teoria de MVVM. É o que acontece quando o app cresce e o @State não foi pensado para crescer junto.

O @State que você jogou no View não é um problema de SwiftUI. É um problema de onde a decisão mora.

Num app financeiro onde trabalhei, a tela de extrato tinha um @State pra loading, um @State pra erro, um @State pra lista de transações e mais dois pra controle de modal. Cinco @State numa View de duzentas linhas. O desenvolvedor que escreveu aquilo não era ruim. Ele estava seguindo os exemplos da documentação oficial da Apple.

O problema não era o código. Era que ninguém tinha decidido onde o estado devia morar.

Essa decisão aparentemente pequena virou dívida técnica em produção em menos de quatro meses.


O que a documentação não conta

SwiftUI torna @State fácil demais de usar. Isso é intencional. A Apple quer que você consiga construir interfaces rápido, com pouco boilerplate. E funciona. Para protótipos, funciona muito bem.

O problema começa quando você usa o mesmo padrão de prototipagem num app que vai crescer, ter mais desenvolvedores, mais regras de negócio e mais estados interdependentes.

@State é estado local de View. O nome diz isso. Mas a prática comum é usar @State pra qualquer coisa que precise reagir à mudança, independente de se aquela coisa é local ou não.

Quando você coloca em @State algo que não é decisão da View, a View passa a tomar decisão que não é dela. E aí começa o problema que você não vai ver no próximo sprint, mas vai ver no terceiro.


O erro que se repete

Trabalhei em codebase com SwiftUI desde quando ele chegou ainda instável no iOS 13. Vi times diferentes cometendo o mesmo erro de formas diferentes.

O padrão mais comum: o ViewModel existe, mas o estado vaza pra View.

struct CheckoutView: View {
    @StateObject var viewModel: CheckoutViewModel
    @State private var isConfirmationVisible = false
    @State private var selectedPaymentIndex = 0
    @State private var isProcessing = false

    var body: some View { ... }
}

O que parece organizado na primeira leitura vira problema quando isProcessing precisa ser sincronizado com o ViewModel, selectedPaymentIndex influencia a lógica de validação e isConfirmationVisible depende do resultado de uma chamada assíncrona.

A View começa a orquestrar lógica. E a lógica que mora na View não tem teste unitário.

Não tem como testar isProcessing sem instanciar a View inteira. Não tem como testar a sequência de estados sem passar pelo ciclo de vida do SwiftUI. O código funciona, mas ele é opaco.


O problema real por trás do problema

Não é sobre @State ser errado. É sobre fronteira de responsabilidade.

SwiftUI convida o desenvolvedor a misturar as camadas porque ele reduz o custo de fazer isso. Você não precisa de delegate, não precisa de closure, não precisa de binding explícito. É $variavel e pronto. Esse custo baixo cria uma armadilha: a facilidade de misturar esconde o preço futuro de separar.

A consequência prática que vi mais vezes: dois desenvolvedores trabalhando em paralelo na mesma feature, cada um adicionando @State pra resolver o seu pedaço, e no final da sprint a View tinha onze variáveis de estado e ninguém conseguia dizer com certeza o fluxo de transição entre elas.

Não é exagero. Aconteceu numa base de código com mais de quarenta módulos Swift, onde o time era bom e o processo era organizado.


O que funciona melhor na prática

A regra que adotei e defendo: se o estado participa de lógica de negócio ou é derivado de uma chamada externa, ele mora no ViewModel. Se o estado é puramente de apresentação e não tem consequência fora da View, @State é o lugar certo.

Isso parece óbvio escrito assim. Mas a linha real de corte é mais sutil.

"Mostrar ou esconder um modal" parece estado de apresentação. Mas se o modal só aparece depois que uma transação foi confirmada, o estado que dispara o modal é consequência de lógica de negócio. Ele devia estar no ViewModel, com a View apenas reagindo a ele.

// ViewModel
@Published var transactionState: TransactionState = .idle

// View
.sheet(isPresented: .constant(viewModel.transactionState == .confirmed)) {
    ConfirmationView()
}

Agora você pode testar a transição de estado sem tocar na View. E quando o design mudar pra usar um banner em vez de modal, você muda só a View. A lógica continua intacta.


O caso do @State que quebrou o fluxo de pagamento

Num fluxo de pagamento que revisei, havia um @State private var isButtonDisabled = false que era atualizado manualmente em três lugares diferentes da View. O botão de confirmar ficava habilitado ou desabilitado dependendo de uma combinação de condições: campos preenchidos, método de pagamento selecionado e ausência de erro.

Cada condição era verificada em locais diferentes. Uma atualização chegava via callback de um campo de texto. Outra via onChange de um Picker. A terceira via resultado de validação inline.

Quando a validação do backend voltou com erro de timeout, o botão ficou habilitado quando não devia. O usuário podia tentar confirmar uma transação com dados inválidos.

O bug não era de SwiftUI. Era de estado distribuído sem dono claro.

A correção foi mover toda lógica de habilitação do botão pra uma propriedade computada no ViewModel:

var isConfirmButtonEnabled: Bool {
    hasValidFields && hasSelectedPayment && transactionState != .loading
}

Uma fonte de verdade. Testável. Sem sincronização manual.


O trade-off honesto

Mover estado pro ViewModel tem custo. O boilerplate aumenta. Você precisa de mais @Published, mais bindings explícitos, mais estrutura pra coisas simples.

Para telas pequenas e estáveis, esse custo não compensa. Uma tela de "sobre o app" com um @State pra expandir um texto não precisa de ViewModel. Isso seria over-engineering real.

O ponto é: a decisão precisa ser consciente. Não "uso @State porque é mais fácil". Mas "uso @State porque esse estado é genuinamente local e não vai participar de lógica nenhuma".

A distinção parece pequena agora. Num time de cinco pessoas depois de seis meses de desenvolvimento, ela é a diferença entre uma codebase que você consegue manter e uma que você tem medo de tocar.


O que eu faria diferente hoje

Eu teria definido essa regra de corte explicitamente com o time, desde o início. Não deixaria pra surgir organicamente. Convenções implícitas não sobrevivem a rotatividade de time.

E teria adotado snapshot tests pra Views mais cedo. Não como substituto de teste unitário de ViewModel, mas como detector de regressão visual. Quando você sabe que a View vai ser testada por screenshot, você pensa duas vezes antes de colocar lógica dentro dela.


A conclusão que fica

SwiftUI não tem problema de estado. Tem problema de decisão.

O framework é poderoso exatamente porque reduz atrito. Mas atrito zero também significa que você não percebe quando está cruzando uma fronteira que vai custar caro depois.

@State na View certa, na quantidade certa, é elegante. @State como substituto de arquitetura é a dívida técnica mais difícil de refatorar porque ela não aparece no lint, não quebra o build e não falha no teste. Ela só aparece quando o produto cresce e o time descobre que ninguém sabe mais quem é o dono do estado.

E aí refatorar não é mais uma decisão técnica. É uma negociação com o roadmap.