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.