<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Sanjay Nelagadde on Medium]]></title>
        <description><![CDATA[Stories by Sanjay Nelagadde on Medium]]></description>
        <link>https://medium.com/@sanjaynelagadde1992?source=rss-85a120b73062------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*mvLjepjj7Z5F5d2Q4DFUbw@2x.jpeg</url>
            <title>Stories by Sanjay Nelagadde on Medium</title>
            <link>https://medium.com/@sanjaynelagadde1992?source=rss-85a120b73062------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sat, 14 Nov 2026 02:56:39 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@sanjaynelagadde1992/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[Coordinator Pattern in SwiftUI: Keeping Navigation Logic Out of Your Views]]></title>
            <link>https://levelup.gitconnected.com/coordinator-pattern-in-swiftui-keeping-navigation-logic-out-of-your-views-48c2fd8e35ab?source=rss-85a120b73062------2</link>
            <guid isPermaLink="false">https://medium.com/p/48c2fd8e35ab</guid>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[apple]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[mobile]]></category>
            <category><![CDATA[ios]]></category>
            <dc:creator><![CDATA[Sanjay Nelagadde]]></dc:creator>
            <pubDate>Mon, 13 Jul 2026 15:29:58 GMT</pubDate>
            <atom:updated>2026-07-13T15:29:58.146Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*eVwUKUjXZVAFKZ5H7CfZzg.png" /></figure><p>When I first started building apps with SwiftUI, navigation felt almost too easy. Add a NavigationLink, use a .sheet, throw in a fullScreenCover, and the next screen appears. For a small project, that simplicity is one of SwiftUI’s biggest strengths. You can move quickly without creating navigation controllers, delegates, or a lot of setup code.</p><p>The problem usually appears later.</p><p>As the app grows, one view starts managing a navigation path, another view controls a sheet, a login screen decides when the entire app should switch to the main flow, and a checkout screen knows exactly which step comes next. Before long, navigation logic is scattered across views, Boolean properties, tab selections, and conditionals.</p><p>The app still works, but it becomes harder to answer a simple question: where is the flow actually controlled?</p><p>This article is part of my ongoing iOS development notes where I write about problems I run into while building apps, so other developers can avoid repeating the same mistakes. I previously wrote <a href="https://medium.com/gitconnected/swiftui-state-in-ios-a-practical-guide-993a70794cdd">SwiftUI State in iOS: A Practical Guide</a>, where I explained how state ownership can either keep a SwiftUI app clean or make it confusing. I also wrote <a href="https://medium.com/gitconnected/swiftui-navigation-in-ios-a-practical-guide-2a4820971681">SwiftUI Navigation in iOS: A Practical Guide</a>, which covers NavigationStack, programmatic navigation, sheets, and navigation destinations. Another related article is <a href="https://blog.stackademic.com/10-common-mistakes-every-ios-developer-should-avoid-learned-the-hard-way-cf01a8830505">10 Common Mistakes Every iOS Developer Should Avoid: Learned the Hard Way</a>. If I had to add one more mistake to that list, it would be letting every SwiftUI view control its own part of the app flow.</p><p>That is where a coordinator, or router, becomes useful.</p><h3>The Problem With Navigation Inside Views</h3><p>Imagine we are building a shopping app. It starts with a product list, and tapping a product opens a detail screen.</p><pre>struct ProductListView: View {<br>    let products: [Product]<br><br>var body: some View {<br>        List(products) { product in<br>            NavigationLink(value: product) {<br>                Text(product.name)<br>            }<br>        }<br>        .navigationTitle(&quot;Products&quot;)<br>    }<br>}</pre><p>There is nothing wrong with this. For simple navigation, NavigationLink is often exactly what we need.</p><p>Now the product list also needs to open the cart, show filters, present login, and start checkout.</p><pre>struct ProductListView: View {<br>    let products: [Product]<br><br>@State private var showingCart = false<br>    @State private var showingFilters = false<br>    @State private var showingLogin = false<br>    @State private var showingCheckout = false<br>    var body: some View {<br>        List(products) { product in<br>            NavigationLink(value: product) {<br>                Text(product.name)<br>            }<br>        }<br>        .toolbar {<br>            Button(&quot;Cart&quot;) {<br>                showingCart = true<br>            }<br>        }<br>        .sheet(isPresented: $showingCart) {<br>            CartView()<br>        }<br>        .sheet(isPresented: $showingFilters) {<br>            FilterView()<br>        }<br>        .sheet(isPresented: $showingLogin) {<br>            LoginView()<br>        }<br>        .fullScreenCover(isPresented: $showingCheckout) {<br>            CheckoutView()<br>        }<br>    }<br>}</pre><p>The view is now doing much more than displaying products. It knows which screens exist, how they should be presented, and which part of the app should appear next. If checkout requires login, the view may also start checking authentication state. If the same cart screen can be opened from the home tab, product detail screen, and profile screen, the same navigation decision gets repeated in several places.</p><p>A SwiftUI view should usually be responsible for showing the interface and reporting what the user did. It should not need to understand the entire journey through the application.</p><h3>Coordinator or Router?</h3><p>The Coordinator Pattern is commonly associated with UIKit, where a coordinator creates view controllers and pushes them onto a navigation controller. SwiftUI works differently because navigation is driven by state.</p><p>In SwiftUI, a coordinator often looks more like a router. It owns navigation state such as the current path, the presented sheet, the selected tab, or the active app flow. Views report actions, the router updates the navigation state, and SwiftUI renders the correct destination.</p><p>Some developers call this object a coordinator. Others call it a router or navigation store. I do not think the name matters as much as the responsibility.</p><p>The main idea is simple:</p><blockquote><em>Views describe the interface. The router owns navigation decisions.</em></blockquote><h3>Creating Typed Routes</h3><p>A clean way to begin is by defining the destinations that can appear in the app.</p><pre>import Foundation<br><br>struct Product: Identifiable, Hashable {<br>    let id: UUID<br>    let name: String<br>    let price: Decimal<br>}<br>enum AppRoute: Hashable {<br>    case productDetail(productID: UUID)<br>    case cart<br>    case settings<br>}</pre><p>I prefer typed routes because they make the navigation structure easy to understand. Instead of pushing random values into a NavigationPath, the app has a clear list of supported destinations.</p><p>Notice that the product detail route stores the product ID instead of the full product. This keeps the route lightweight and allows the destination to load fresh data when it appears.</p><h3>Building the Router</h3><p>For an app supporting iOS 17 or later, we can use the Observation framework.</p><pre>import Observation<br><br>@MainActor<br>@Observable<br>final class AppRouter {<br>    var path: [AppRoute] = []<br>    func showProduct(_ product: Product) {<br>        path.append(.productDetail(productID: product.id))<br>    }<br>    func showCart() {<br>        path.append(.cart)<br>    }<br>    func showSettings() {<br>        path.append(.settings)<br>    }<br>    func goBack() {<br>        guard !path.isEmpty else { return }<br>        path.removeLast()<br>    }<br>    func popToRoot() {<br>        path.removeAll()<br>    }<br>}</pre><p>The router now owns the navigation path. A view no longer needs to know how the destination is created or how the stack should be changed. It only calls the appropriate action.</p><pre>router.showCart()</pre><p>That may look like a small abstraction, but it gives us one place to change the behavior later. For example, if opening the cart should require login, we can add that rule inside the navigation layer instead of updating every button that opens the cart.</p><h3>Connecting the Router to NavigationStack</h3><p>The root view owns the router and maps each route to a destination.</p><pre>import SwiftUI<br><br>struct AppRootView: View {<br>    @State private var router = AppRouter()<br>    let products: [Product]<br>    let productService: ProductServiceProtocol<br>    var body: some View {<br>        @Bindable var router = router<br>        NavigationStack(path: $router.path) {<br>            ProductListView(products: products)<br>                .navigationDestination(for: AppRoute.self) { route in<br>                    switch route {<br>                    case .productDetail(let productID):<br>                        ProductDetailView(<br>                            viewModel: ProductDetailViewModel(<br>                                productID: productID,<br>                                productService: productService<br>                            )<br>                        )<br>                    case .cart:<br>                        CartView()<br>                    case .settings:<br>                        SettingsView()<br>                    }<br>                }<br>        }<br>        .environment(router)<br>    }<br>}</pre><p>This root view becomes the place where destinations are built and dependencies are passed. The product list does not need to know that ProductDetailView uses a view model or that the view model needs a service. It only needs to report which product the user selected.</p><p>The product list becomes much simpler.</p><pre>struct ProductListView: View {<br>    @Environment(AppRouter.self) private var router<br><br>    let products: [Product]<br>    var body: some View {<br>        List(products) { product in<br>            Button {<br>                router.showProduct(product)<br>            } label: {<br>                HStack {<br>                    Text(product.name)<br>                    Spacer()<br>                    Image(systemName: &quot;chevron.right&quot;)<br>                        .foregroundStyle(.secondary)<br>                }<br>            }<br>        }<br>        .navigationTitle(&quot;Products&quot;)<br>        .toolbar {<br>            Button {<br>                router.showCart()<br>            } label: {<br>                Image(systemName: &quot;cart&quot;)<br>            }<br>        }<br>    }<br>}</pre><p>The view handles the user action. The router handles what that action means for navigation.</p><p>That separation is the real value of the pattern.</p><h3>Managing Sheets Without Many Boolean Properties</h3><p>One of the first warning signs in a SwiftUI view is a growing collection of Boolean presentation properties.</p><pre>@State private var showingLogin = false<br>@State private var showingFilters = false<br>@State private var showingProfile = false<br>@State private var showingSettings = false</pre><p>This works, but it becomes difficult to manage as the number of sheets grows. It also allows multiple values to become true even though only one sheet should normally be visible.</p><p>A typed sheet route is usually cleaner.</p><pre>enum SheetRoute: Identifiable {<br>    case login<br>    case filters<br>    case profile<br>    case settings<br>    var id: String {<br>        String(describing: self)<br>    }<br>}</pre><p>The router can own one optional presentation.</p><pre>@MainActor<br>@Observable<br>final class AppRouter {<br>    var path: [AppRoute] = []<br>    var presentedSheet: SheetRoute?<br><br>    func showProduct(_ product: Product) {<br>        path.append(.productDetail(productID: product.id))<br>    }<br>    func showCart() {<br>        path.append(.cart)<br>    }<br>    func showLogin() {<br>        presentedSheet = .login<br>    }<br>    func showFilters() {<br>        presentedSheet = .filters<br>    }<br>    func dismissSheet() {<br>        presentedSheet = nil<br>    }<br>}</pre><p>The root view handles sheet destinations in one place.</p><pre>.sheet(item: $router.presentedSheet) { sheet in<br>    switch sheet {<br>    case .login:<br>        LoginView()<br>    case .filters:<br>        FilterView()<br>    case .profile:<br>        ProfileView()<br>    case .settings:<br>        SettingsView()<br>    }<br>}</pre><p>Now the app has one source of truth for sheet presentation instead of several unrelated Boolean values.</p><h3>Coordinating Authentication</h3><p>The pattern becomes even more useful when the app has major flows such as loading, onboarding, authentication, and the main interface.</p><pre>enum AppFlow {<br>    case loading<br>    case onboarding<br>    case authentication<br>    case main<br>}</pre><p>An app coordinator can own the current flow.</p><pre>@MainActor<br>@Observable<br>final class AppCoordinator {<br>    var flow: AppFlow = .loading<br>    private let sessionService: SessionServiceProtocol<br>    private let onboardingService: OnboardingServiceProtocol<br>    init(<br>        sessionService: SessionServiceProtocol,<br>        onboardingService: OnboardingServiceProtocol<br>    ) {<br>        self.sessionService = sessionService<br>        self.onboardingService = onboardingService<br>    }<br>    func start() {<br>        if !onboardingService.hasCompletedOnboarding {<br>            flow = .onboarding<br>        } else if sessionService.isLoggedIn {<br>            flow = .main<br>        } else {<br>            flow = .authentication<br>        }<br>    }<br>    func didCompleteOnboarding() {<br>        flow = sessionService.isLoggedIn ? .main : .authentication<br>    }<br>    func didLogin() {<br>        flow = .main<br>    }<br>    func didLogout() {<br>        flow = .authentication<br>    }<br>}</pre><p>The app container displays the correct root based on that flow.</p><pre>struct AppContainerView: View {<br>    @State private var coordinator: AppCoordinator<br><br>init(<br>        sessionService: SessionServiceProtocol,<br>        onboardingService: OnboardingServiceProtocol<br>    ) {<br>        _coordinator = State(<br>            initialValue: AppCoordinator(<br>                sessionService: sessionService,<br>                onboardingService: onboardingService<br>            )<br>        )<br>    }<br>    var body: some View {<br>        Group {<br>            switch coordinator.flow {<br>            case .loading:<br>                ProgressView()<br>            case .onboarding:<br>                OnboardingView {<br>                    coordinator.didCompleteOnboarding()<br>                }<br>            case .authentication:<br>                LoginView {<br>                    coordinator.didLogin()<br>                }<br>            case .main:<br>                MainTabView {<br>                    coordinator.didLogout()<br>                }<br>            }<br>        }<br>        .task {<br>            coordinator.start()<br>        }<br>    }<br>}</pre><p>The login view does not replace the root interface itself. It reports that login succeeded. The coordinator decides that the main flow should now appear.</p><p>This keeps authentication flow out of individual screens and gives the application one clear place where root-level transitions happen.</p><h3>Using a Separate Coordinator for Checkout</h3><p>A multi-step flow is another good use case.</p><p>A checkout process may include address, payment, review, and confirmation. Without a coordinator, each screen may start knowing which screen comes next. The address view opens payment, payment opens review, and review opens confirmation.</p><p>A separate checkout coordinator keeps that journey together.</p><pre>enum CheckoutRoute: Hashable {<br>    case address<br>    case payment<br>    case review<br>    case confirmation<br>}<br><br>@MainActor<br>@Observable<br>final class CheckoutCoordinator {<br>    var path: [CheckoutRoute] = []<br><br><br><br>private(set) var address: ShippingAddress?<br>    private(set) var paymentMethod: PaymentMethod?<br>    func start() {<br>        path = [.address]<br>    }<br>    func didEnterAddress(_ address: ShippingAddress) {<br>        self.address = address<br>        path.append(.payment)<br>    }<br>    func didSelectPayment(_ paymentMethod: PaymentMethod) {<br>        self.paymentMethod = paymentMethod<br>        path.append(.review)<br>    }<br>    func didConfirmOrder() {<br>        path.append(.confirmation)<br>    }<br>}</pre><p>Each screen reports what the user completed, and the coordinator moves the flow forward.</p><pre>struct CheckoutContainerView: View {<br>    @State private var coordinator = CheckoutCoordinator()<br><br>    var body: some View {<br>          @Bindable var coordinator = coordinator<br>          NavigationStack(path: $coordinator.path) {<br>              Color.clear<br>                  .navigationDestination(for: CheckoutRoute.self) { route in<br>                      switch route {<br>                      case .address:<br>                          AddressView { address in<br>                              coordinator.didEnterAddress(address)<br>                          }<br>                      case .payment:<br>                          PaymentView { paymentMethod in<br>                              coordinator.didSelectPayment(paymentMethod)<br>                          }<br>                      case .review:<br>                          if let address = coordinator.address,<br>                             let paymentMethod = coordinator.paymentMethod {<br>                              ReviewOrderView(<br>                                  address: address,<br>                                  paymentMethod: paymentMethod<br>                              ) {<br>                                  coordinator.didConfirmOrder()<br>                              }<br>                          }<br>                      case .confirmation:<br>                          OrderConfirmationView()<br>                      }<br>                  }<br>          }<br>          .task {<br>              coordinator.start()<br>          }<br>      }<br>}</pre><p>If we later add a coupon screen between address and payment, the checkout coordinator changes. The individual views do not need to understand the new order of the flow.</p><h3>Coordinator Pattern With MVVM</h3><p>The Coordinator Pattern works well with MVVM because each layer has a clear job.</p><p>The view displays state and reports user actions. The view model handles screen-specific logic and data loading. Services or repositories handle external data. The router handles navigation.</p><p>For example, a product detail view model may load the product.</p><pre>@MainActor<br>@Observable<br>final class ProductDetailViewModel {<br>    private let productID: UUID<br>    private let productService: ProductServiceProtocol<br><br>private(set) var product: Product?<br>    private(set) var isLoading = false<br>    private(set) var errorMessage: String?<br>    init(<br>        productID: UUID,<br>        productService: ProductServiceProtocol<br>    ) {<br>        self.productID = productID<br>        self.productService = productService<br>    }<br>    func loadProduct() async {<br>        isLoading = true<br>        defer { isLoading = false }<br>        do {<br>            product = try await productService.product(withID: productID)<br>        } catch {<br>            errorMessage = error.localizedDescription<br>        }<br>    }<br>}</pre><p>The view uses the view model for data and the router for navigation.</p><pre>struct ProductDetailView: View {<br>    @State private var viewModel: ProductDetailViewModel<br>    @Environment(AppRouter.self) private var router<br><br>    init(viewModel: ProductDetailViewModel) {<br>        _viewModel = State(initialValue: viewModel)<br>    }<br>    var body: some View {<br>        Group {<br>            if viewModel.isLoading {<br>                ProgressView()<br>            } else if let product = viewModel.product {<br>                VStack(spacing: 16) {<br>                    Text(product.name)<br>                        .font(.title)<br>                    Text(<br>                        product.price,<br>                        format: .currency(code: &quot;USD&quot;)<br>                    )<br>                    Button(&quot;Open Cart&quot;) {<br>                        router.showCart()<br>                    }<br>                }<br>            } else if let errorMessage = viewModel.errorMessage {<br>                Text(errorMessage)<br>            }<br>        }<br>        .task {<br>            await viewModel.loadProduct()<br>        }<br>    }<br>}</pre><p>The view model does not control navigation, and the router does not load product data. That separation keeps both objects focused.</p><h3>Testing Navigation Becomes Easier</h3><p>Because navigation is stored as state, it can be tested without rendering the full SwiftUI hierarchy.</p><pre>import XCTest<br><br>@MainActor<br>final class AppRouterTests: XCTestCase {<br>    func testShowProductAddsProductRoute() {<br>        let router = AppRouter()<br>        let product = Product(<br>            id: UUID(),<br>            name: &quot;iPhone Case&quot;,<br>            price: 29.99<br>        )<br>        router.showProduct(product)<br>        XCTAssertEqual(<br>            router.path,<br>            [.productDetail(productID: product.id)]<br>        )<br>    }<br>    func testPopToRootClearsNavigation() {<br>        let router = AppRouter()<br>        let product = Product(<br>            id: UUID(),<br>            name: &quot;MacBook Sleeve&quot;,<br>            price: 49.99<br>        )<br>        router.showProduct(product)<br>        router.showCart()<br>        router.popToRoot()<br>        XCTAssertTrue(router.path.isEmpty)<br>    }<br>}</pre><p>The test focuses on the navigation decision itself. It does not need to inspect a private Boolean property or simulate a complete interface.</p><h3>Common Mistakes</h3><p>The Coordinator Pattern can help, but it can also be overdone.</p><p>The first mistake is creating one enormous global router that controls every stack, sheet, tab, alert, and feature. That router eventually becomes the SwiftUI version of a massive view controller. Major flows should usually have their own navigation owner, such as an app coordinator, a main-tab coordinator, and a checkout coordinator.</p><p>The second mistake is putting business logic inside the router. A router can decide where the app goes, but it should not perform network requests, validate forms, calculate prices, or transform API responses. That work belongs in view models and services.</p><p>The third mistake is using a global singleton such as AppRouter.shared. It is convenient, but it creates hidden dependencies and shared mutable state that make previews and tests harder. Passing the router through the environment usually keeps ownership clearer.</p><p>The fourth mistake is creating a coordinator for every screen. A simple static page may only need a NavigationLink. Use the pattern when it removes actual complexity, not because every view must follow the same architecture.</p><h3>When Should You Use It?</h3><p>I would consider a coordinator or router when navigation is repeated across several views, the app has authentication or onboarding, a feature has multiple steps, destinations require several dependencies, tabs need separate navigation histories, or deep links must reach specific screens.</p><p>I would not force it into a small app with two or three simple destinations. Direct SwiftUI navigation is often cleaner in that situation.</p><p>The pattern becomes valuable when the navigation logic starts becoming harder to follow than the screens themselves.</p><h3>Final Thoughts</h3><p>The Coordinator Pattern in SwiftUI is not exactly the same as the UIKit version. Instead of pushing view controllers, we update navigation state using NavigationStack, typed routes, sheets, tab selection, and root-flow state.</p><p>The goal, however, is the same.</p><p>Views should not need to understand the entire application journey.</p><p>A product view can say that the user selected a product. A login view can say that authentication succeeded. An address view can say that the user finished entering an address.</p><p>The coordinator decides what happens next.</p><p>For me, this pattern started making sense only after navigation logic became scattered across several views. That is usually how architecture concepts become useful. They feel unnecessary until the code starts showing you the problem they were designed to solve.</p><p>Start small. Move one growing navigation stack into a router. Move one multi-step checkout or onboarding flow into its own coordinator. Keep the parts that make the app easier to understand, and avoid adding structure that does not solve a real problem.</p><p>The goal is not more architecture. The goal is cleaner navigation.</p><p>If this article helped you understand how the Coordinator Pattern fits into SwiftUI, please clap for the article and follow me for more iOS development notes. I am continuing to write about the problems I run into while building apps, so hopefully you can learn from them without having to make every mistake yourself.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=48c2fd8e35ab" width="1" height="1" alt=""><hr><p><a href="https://levelup.gitconnected.com/coordinator-pattern-in-swiftui-keeping-navigation-logic-out-of-your-views-48c2fd8e35ab">Coordinator Pattern in SwiftUI: Keeping Navigation Logic Out of Your Views</a> was originally published in <a href="https://levelup.gitconnected.com/">Level Up Coding</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[I Tried the New TypeScript: Why TypeScript 7 Actually Feels Like a Big Deal]]></title>
            <link>https://levelup.gitconnected.com/i-tried-the-new-typescript-why-typescript-7-actually-feels-like-a-big-deal-843215898673?source=rss-85a120b73062------2</link>
            <guid isPermaLink="false">https://medium.com/p/843215898673</guid>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[typescript]]></category>
            <category><![CDATA[javascript]]></category>
            <category><![CDATA[web-development]]></category>
            <dc:creator><![CDATA[Sanjay Nelagadde]]></dc:creator>
            <pubDate>Wed, 01 Jul 2026 17:10:18 GMT</pubDate>
            <atom:updated>2026-07-01T17:10:18.629Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qP3rcdMFbYKqip_kMc1NDw.png" /></figure><p>I have been writing more articles around full-stack development lately, mostly because I keep running into real issues while building things. Some of them are small issues, like a confusing config error that wastes an entire evening. Some of them are bigger decisions, like choosing between React and Next.js, structuring a project properly, or figuring out how to keep frontend and backend code clean as the app grows. Writing these articles has become my way of tracking what I learn, what confused me, and what I wish I had understood earlier, so other developers do not have to repeat the same mistakes.</p><p>In one of my previous articles, <a href="https://javascript.plainenglish.io/from-react-to-next-js-why-i-finally-switched-and-what-i-learned-32bb376f7bf1">From React to Next.js: Why I Finally Switched and What I Learned</a>, I wrote about why I finally started seeing Next.js as more than just “React with routing.” It was more about how projects change when they become real. In another article, <a href="https://levelup.gitconnected.com/building-a-live-github-portfolio-with-react-next-js-tailwind-part-2-6f0ad5915234">Building a Live GitHub Portfolio with React, Next.js, Tailwind — Part 2</a>, I went deeper into a practical project and explained how React, Next.js, Tailwind, and GitHub APIs can come together in something useful. This article continues that same full-stack journey, but this time I want to talk about something that quietly affects almost every modern JavaScript project: TypeScript.</p><p>For a long time, I treated TypeScript as one of those tools that just sits in the background. You create a Next.js app, and TypeScript is there. You build a Node.js API, and TypeScript is there. You create shared types between frontend and backend, and TypeScript is there. At first, everything feels great because the project is small, the editor is fast, and type errors are easy to understand. But once the project grows, you start noticing the pain. Auto-complete becomes slower. Type checking takes longer. CI spends more time on tsc --noEmit. Your editor sometimes feels like it is thinking too hard before telling you something obvious.</p><p>That is why the new TypeScript caught my attention. TypeScript 7 is not just another version with a few small language changes. The big story is that the TypeScript team has been working on a native compiler and language service, written in Go, with the goal of making TypeScript much faster while keeping the language familiar. In simple words, TypeScript is not trying to become a different language. It is trying to become the same TypeScript we already use, but much faster and more pleasant in larger projects.</p><h3>Why This Matters for Full-Stack Developers</h3><p>Speed may not sound exciting when you are working on a tiny demo project. If your app has five components and one API route, TypeScript usually feels instant. But full-stack projects rarely stay tiny. You start with a simple componentsfolder, then you add lib, server, api, types, hooks, services, db, and maybe some generated types from an ORM or API client. After a while, TypeScript is no longer checking a few files. It is checking your entire application world.</p><p>A typical full-stack project can quickly start looking like this:</p><pre>src/<br>  app/<br>  components/<br>  hooks/<br>  lib/<br>  server/<br>  services/<br>  db/<br>  types/<br>  schemas/<br>  api/</pre><p>Then you add shared request and response types. Then Zod schemas. Then test files. Then database models. Then maybe a monorepo with frontend and backend packages. At that point, TypeScript speed really starts to matter because it affects your everyday flow. It affects how quickly you get errors in the editor, how fast “go to definition” works, how painful refactors feel, and how long your CI checks take before you can merge code.</p><p>That is why I think TypeScript 7 is important. It is not only about faster builds. It is about making the developer experience smoother in projects that have grown past the tutorial stage.</p><h3>TypeScript 6 and TypeScript 7: What Is Actually New?</h3><p>The transition can be a little confusing, so the way I think about it is this: TypeScript 6 is the stable step forward, while TypeScript 7 is the big native compiler preview. TypeScript 6 prepares the ecosystem and removes or tightens certain older behaviors. TypeScript 7 is where the native implementation becomes the headline.</p><p>For production apps, I would not immediately replace everything with TypeScript 7 and hope for the best. I would first make sure the project works cleanly on the latest stable TypeScript version. Then I would try TypeScript 7 side by side. This gives you a safer migration path and helps you understand whether a problem is coming from your project configuration, the TypeScript 6 upgrade, or the TypeScript 7 native preview.</p><p>For a normal project, you can install the stable TypeScript package like this:</p><pre>npm install -D typescript</pre><p>Then check the version:</p><pre>npx tsc --version</pre><p>I usually like having a dedicated type-check script in package.json:</p><pre>{<br>  &quot;scripts&quot;: {<br>    &quot;typecheck&quot;: &quot;tsc --noEmit&quot;<br>  }<br>}</pre><p>Then I can run:</p><pre>npm run typecheck</pre><p>This may sound basic, but I like keeping type checking separate from the build step. In a Next.js project, for example, next build already does many things. When something fails, it is easier to debug if I can run TypeScript separately.</p><pre>{<br>  &quot;scripts&quot;: {<br>    &quot;dev&quot;: &quot;next dev&quot;,<br>    &quot;build&quot;: &quot;next build&quot;,<br>    &quot;typecheck&quot;: &quot;tsc --noEmit&quot;,<br>    &quot;lint&quot;: &quot;next lint&quot;<br>  }<br>}</pre><p>With this setup, I can run things in a cleaner order:</p><pre>npm run typecheck<br>npm run lint<br>npm run build</pre><p>If typecheck fails, I fix TypeScript first. If lint fails, I clean up lint issues. If the build fails after that, I know it is probably related to bundling, runtime code, framework config, or something outside plain type checking.</p><h3>Trying the TypeScript 7 Native Preview</h3><p>The nice thing about the TypeScript 7 preview is that you do not have to throw away your existing TypeScript setup just to experiment with it. You can install the native preview separately:</p><pre>npm install -D @typescript/native-preview@beta</pre><p>Then you can check the native TypeScript version with:</p><pre>npx tsgo --version</pre><p>The command is tsgo, not tsc, which is actually helpful because it lets you compare both compilers without immediately replacing your existing workflow.</p><p>For example, you can run the normal TypeScript compiler:</p><pre>npx tsc --noEmit</pre><p>Then run the native preview:</p><pre>npx tsgo --noEmit</pre><p>In package.json, I would keep both scripts instead of replacing typecheck immediately:</p><pre>{<br>  &quot;scripts&quot;: {<br>    &quot;typecheck&quot;: &quot;tsc --noEmit&quot;,<br>    &quot;typecheck:native&quot;: &quot;tsgo --noEmit&quot;<br>  },<br>  &quot;devDependencies&quot;: {<br>    &quot;typescript&quot;: &quot;^6.0.0&quot;,<br>    &quot;@typescript/native-preview&quot;: &quot;beta&quot;<br>  }<br>}</pre><p>Now you can run:</p><pre>npm run typecheck<br>npm run typecheck:native</pre><p>This is the kind of upgrade style I prefer because it is safe. You are not betting the whole project on a preview compiler. You are comparing behavior, checking errors, and seeing whether the faster compiler fits into your workflow.</p><h3>The Real Problem TypeScript Solves</h3><p>Before going deeper into configuration, I want to talk about something I had to learn the hard way: TypeScript is not runtime validation. It helps you before your code runs, but it does not magically protect you from bad data coming from an API, database, or third-party service.</p><p>Let’s say we have a simple backend function that returns a user:</p><pre>// server/users.ts<br><br>export type User = {<br>  id: string;<br>  name: string;<br>  email: string;<br>  role: &quot;admin&quot; | &quot;user&quot;;<br>  createdAt: string;<br>};<br><br>export async function getUserById(id: string): Promise&lt;User&gt; {<br>  return {<br>    id,<br>    name: &quot;Sanjay&quot;,<br>    email: &quot;sanjay@example.com&quot;,<br>    role: &quot;admin&quot;,<br>    createdAt: new Date().toISOString()<br>  };<br>}</pre><p>On the frontend, we may write something like this:</p><pre>// lib/api.ts<br><br>import type { User } from &quot;@/server/users&quot;;<br><br>export async function fetchUser(id: string): Promise&lt;User&gt; {<br>  const response = await fetch(`/api/users/${id}`);<br><br>  if (!response.ok) {<br>    throw new Error(&quot;Failed to fetch user&quot;);<br>  }<br><br>  return response.json();<br>}</pre><p>This looks clean, but there is a problem. response.json() returns any, which means TypeScript is trusting us more than it should. If the backend accidentally returns this:</p><pre>{<br>  &quot;id&quot;: &quot;123&quot;,<br>  &quot;name&quot;: &quot;Sanjay&quot;,<br>  &quot;email&quot;: &quot;sanjay@example.com&quot;,<br>  &quot;role&quot;: &quot;super-admin&quot;,<br>  &quot;createdAt&quot;: &quot;2026-06-15T12:00:00.000Z&quot;<br>}</pre><p>TypeScript will not automatically stop that at runtime. Our type says role should be &quot;admin&quot; or &quot;user&quot;, but the API returned &quot;super-admin&quot;. The compiler cannot protect us from data that arrives while the app is running.</p><p>This is why I now prefer combining TypeScript with runtime validation for important boundaries. A good example is using Zod:</p><pre>// lib/user-schema.ts<br><br>import { z } from &quot;zod&quot;;<br><br>export const UserSchema = z.object({<br>  id: z.string(),<br>  name: z.string(),<br>  email: z.string().email(),<br>  role: z.enum([&quot;admin&quot;, &quot;user&quot;]),<br>  createdAt: z.string()<br>});<br><br>export type User = z.infer&lt;typeof UserSchema&gt;;</pre><p>Then the API call becomes safer:</p><pre>// lib/api.ts<br><br>import { UserSchema, type User } from &quot;./user-schema&quot;;<br><br>export async function fetchUser(id: string): Promise&lt;User&gt; {<br>  const response = await fetch(`/api/users/${id}`);<br><br>  if (!response.ok) {<br>    throw new Error(&quot;Failed to fetch user&quot;);<br>  }<br><br>  const data: unknown = await response.json();<br><br>  return UserSchema.parse(data);<br>}</pre><p>This is a much better pattern. TypeScript gives us compile-time safety, and Zod gives us runtime validation. In full-stack apps, this combination matters a lot because bugs usually happen at boundaries: frontend to API, API to database, database to external service, and external service back to UI.</p><h3>A More Practical Next.js Example</h3><p>Let’s make this more realistic with a simple product API. Imagine we have this route in a Next.js app:</p><pre>// app/api/products/route.ts<br><br>import { NextResponse } from &quot;next/server&quot;;<br><br>type Product = {<br>  id: string;<br>  name: string;<br>  price: number;<br>  inStock: boolean;<br>};<br><br>const products: Product[] = [<br>  {<br>    id: &quot;1&quot;,<br>    name: &quot;Keyboard&quot;,<br>    price: 99,<br>    inStock: true<br>  },<br>  {<br>    id: &quot;2&quot;,<br>    name: &quot;Mouse&quot;,<br>    price: 49,<br>    inStock: false<br>  }<br>];<br><br>export async function GET() {<br>  return NextResponse.json(products);<br>}</pre><p>Then on the client side, we may fetch products like this:</p><pre>// lib/products.ts<br><br>type Product = {<br>  id: string;<br>  name: string;<br>  price: number;<br>  inStock: boolean;<br>};<br><br>export async function getProducts(): Promise&lt;Product[]&gt; {<br>  const response = await fetch(&quot;/api/products&quot;);<br><br>  if (!response.ok) {<br>    throw new Error(&quot;Failed to fetch products&quot;);<br>  }<br><br>  return response.json();<br>}</pre><p>Again, this works, but it is a little too trusting. I prefer moving the schema to a shared place:</p><pre>// lib/schemas/product.ts<br><br>import { z } from &quot;zod&quot;;<br><br>export const ProductSchema = z.object({<br>  id: z.string(),<br>  name: z.string(),<br>  price: z.number(),<br>  inStock: z.boolean()<br>});<br><br>export const ProductListSchema = z.array(ProductSchema);<br><br>export type Product = z.infer&lt;typeof ProductSchema&gt;;</pre><p>Now the API can use the shared type:</p><pre>// app/api/products/route.ts<br><br>import { NextResponse } from &quot;next/server&quot;;<br>import type { Product } from &quot;@/lib/schemas/product&quot;;<br><br>const products: Product[] = [<br>  {<br>    id: &quot;1&quot;,<br>    name: &quot;Keyboard&quot;,<br>    price: 99,<br>    inStock: true<br>  },<br>  {<br>    id: &quot;2&quot;,<br>    name: &quot;Mouse&quot;,<br>    price: 49,<br>    inStock: false<br>  }<br>];<br><br>export async function GET() {<br>  return NextResponse.json(products);<br>}</pre><p>And the client can validate the response:</p><pre>// lib/products.ts<br><br>import {<br>  ProductListSchema,<br>  type Product<br>} from &quot;@/lib/schemas/product&quot;;<br><br>export async function getProducts(): Promise&lt;Product[]&gt; {<br>  const response = await fetch(&quot;/api/products&quot;);<br><br>  if (!response.ok) {<br>    throw new Error(&quot;Failed to fetch products&quot;);<br>  }<br><br>  const data: unknown = await response.json();<br><br>  return ProductListSchema.parse(data);<br>}</pre><p>This is the kind of full-stack TypeScript pattern I like now. I do not want types just for decoration. I want types that actually help me build safer features.</p><h3>Why Strict TypeScript Is Annoying but Worth It</h3><p>Strict TypeScript can be annoying, especially when you are moving fast. There are times when you just want to build the feature and TypeScript keeps complaining. But most of the time, when TypeScript complains, it is pointing at something that could become a real bug later.</p><p>Here is a simple example:</p><pre>type User = {<br>  name: string;<br>  email?: string;<br>};<br><br>function sendWelcomeEmail(user: User) {<br>  console.log(user.email.toLowerCase());<br>}</pre><p>At first glance, this looks fine. But email is optional, so this can crash:</p><pre>sendWelcomeEmail({ name: &quot;Sanjay&quot; });</pre><p>With strict checks enabled, TypeScript forces us to deal with that case:</p><pre>function sendWelcomeEmail(user: User) {<br>  if (!user.email) {<br>    console.log(&quot;No email found for user&quot;);<br>    return;<br>  }<br><br>  console.log(user.email.toLowerCase());<br>}</pre><p>Or, if the email is truly required, we should change the type:</p><pre>type User = {<br>  name: string;<br>  email: string;<br>};</pre><p>This is a small example, but these small things add up in a real app. A lot of bugs are not dramatic. They come from one optional field, one API response that changed shape, one missing null check, or one wrong assumption. Strict TypeScript helps catch those mistakes earlier.</p><h3>Config Changes That Can Surprise You</h3><p>One thing I have learned with TypeScript is that the language itself is usually not the problem. The config is where people get stuck. A small tsconfig.json change can make the difference between a project that feels clean and a project that constantly throws confusing errors.</p><p>For example, many projects use path aliases like this:</p><pre>import { Button } from &quot;@/components/Button&quot;;</pre><p>Older configs often look like this:</p><pre>{<br>  &quot;compilerOptions&quot;: {<br>    &quot;baseUrl&quot;: &quot;.&quot;,<br>    &quot;paths&quot;: {<br>      &quot;@/*&quot;: [&quot;src/*&quot;]<br>    }<br>  }<br>}</pre><p>A cleaner approach is to make the path mapping explicit:</p><pre>{<br>  &quot;compilerOptions&quot;: {<br>    &quot;paths&quot;: {<br>      &quot;@/*&quot;: [&quot;./src/*&quot;]<br>    }<br>  }<br>}</pre><p>Your import stays the same:</p><pre>import { Button } from &quot;@/components/Button&quot;;</pre><p>But the config is clearer because the alias directly points to ./src/*.</p><p>Another setting I like being explicit about is rootDir. If your project looks like this:</p><pre>my-app/<br>  tsconfig.json<br>  src/<br>    index.ts<br>    api/<br>      users.ts</pre><p>Your config may work without rootDir, but I still prefer writing it clearly:</p><pre>{<br>  &quot;compilerOptions&quot;: {<br>    &quot;rootDir&quot;: &quot;./src&quot;,<br>    &quot;outDir&quot;: &quot;./dist&quot;,<br>    &quot;module&quot;: &quot;esnext&quot;,<br>    &quot;target&quot;: &quot;es2022&quot;<br>  },<br>  &quot;include&quot;: [&quot;src&quot;]<br>}</pre><p>This helps future you understand the project faster. And honestly, future me always appreciates when past me took five extra minutes to make config readable.</p><h3>Side-Effect Imports and CSS Files</h3><p>Another stricter area to watch is side-effect imports. These are imports where you are not importing a value, but you still want the file to run or load.</p><p>A common example is CSS:</p><pre>import &quot;./globals.css&quot;;</pre><p>This is normal in many frontend projects. But imagine you accidentally type:</p><pre>import &quot;./gloabls.css&quot;;</pre><p>That typo can be annoying to debug, especially if your tooling does not catch it where you expect. Stricter TypeScript behavior around side-effect imports helps catch issues like this earlier.</p><p>If TypeScript complains about importing CSS files, you may need a declaration file:</p><pre>// global.d.ts<br><br>declare module &quot;*.css&quot;;</pre><p>For CSS modules, you can use:</p><pre>// global.d.ts<br><br>declare module &quot;*.module.css&quot; {<br>  const classes: Record&lt;string, string&gt;;<br>  export default classes;<br>}</pre><p>This is one of those things that feels irritating the first time you see it, but it makes sense. TypeScript is basically saying, “I do not know what this file type is. Please tell me.” Once you tell it, the project becomes cleaner.</p><h3>Import Attributes Instead of Import Assertions</h3><p>Another migration detail worth knowing is the move from import assertions to import attributes. Older code may look like this:</p><pre>import config from &quot;./config.json&quot; assert { type: &quot;json&quot; };</pre><p>The newer syntax uses with:</p><pre>import config from &quot;./config.json&quot; with { type: &quot;json&quot; };</pre><p>For example, if you have a JSON config file:</p><pre>{<br>  &quot;appName&quot;: &quot;Portfolio&quot;,<br>  &quot;version&quot;: &quot;1.0.0&quot;<br>}</pre><p>You can import it like this:</p><pre>import appConfig from &quot;./app-config.json&quot; with { type: &quot;json&quot; };<br><br>console.log(appConfig.appName);<br>console.log(appConfig.version);</pre><p>This is not something that will affect every project, but if your app imports JSON modules directly, it is worth checking. These small syntax changes are the kind of things that can randomly break a build during an upgrade if you are not expecting them.</p><h3>Be Explicit With Global Types</h3><p>Testing is another place where TypeScript config can surprise you. If you are using Vitest or Jest, your test files may use globals like describe, it, and expect:</p><pre>describe(&quot;sum&quot;, () =&gt; {<br>  it(&quot;adds two numbers&quot;, () =&gt; {<br>    expect(1 + 2).toBe(3);<br>  });<br>});</pre><p>If TypeScript does not know about those globals, you may see errors like:</p><pre>Cannot find name &#39;describe&#39;<br>Cannot find name &#39;it&#39;<br>Cannot find name &#39;expect&#39;</pre><p>The fix is usually to explicitly tell TypeScript which global types your project uses. For Vitest:</p><pre>{<br>  &quot;compilerOptions&quot;: {<br>    &quot;types&quot;: [&quot;node&quot;, &quot;vitest&quot;]<br>  }<br>}</pre><p>For Jest:</p><pre>{<br>  &quot;compilerOptions&quot;: {<br>    &quot;types&quot;: [&quot;node&quot;, &quot;jest&quot;]<br>  }<br>}</pre><p>I actually like this direction because it makes the project more predictable. Instead of TypeScript pulling in whatever types happen to exist in node_modules, you are telling it exactly what environment the project expects.</p><h3>Using satisfies for Safer Config Objects</h3><p>One TypeScript pattern I use more often now is satisfies. It is useful when you want TypeScript to check that an object follows a certain shape without losing the specific details of the object.</p><p>Imagine you have app routes:</p><pre>type AppRoute = {<br>  label: string;<br>  href: string;<br>  protected: boolean;<br>};<br><br>const routes = {<br>  home: {<br>    label: &quot;Home&quot;,<br>    href: &quot;/&quot;,<br>    protected: false<br>  },<br>  dashboard: {<br>    label: &quot;Dashboard&quot;,<br>    href: &quot;/dashboard&quot;,<br>    protected: true<br>  }<br>} satisfies Record&lt;string, AppRoute&gt;;</pre><p>Now TypeScript checks that every route follows the AppRoute structure. If I accidentally write url instead of href, TypeScript catches it:</p><pre>const routes = {<br>  home: {<br>    label: &quot;Home&quot;,<br>    href: &quot;/&quot;,<br>    protected: false<br>  },<br>  dashboard: {<br>    label: &quot;Dashboard&quot;,<br>    url: &quot;/dashboard&quot;,<br>    protected: true<br>  }<br>} satisfies Record&lt;string, AppRoute&gt;;</pre><p>The correct version should use href:</p><pre>const routes = {<br>  home: {<br>    label: &quot;Home&quot;,<br>    href: &quot;/&quot;,<br>    protected: false<br>  },<br>  dashboard: {<br>    label: &quot;Dashboard&quot;,<br>    href: &quot;/dashboard&quot;,<br>    protected: true<br>  }<br>} satisfies Record&lt;string, AppRoute&gt;;</pre><p>This is not only about TypeScript 7, but it is part of the modern TypeScript style I like using in full-stack apps. You get safer config, better autocomplete, and fewer silly mistakes.</p><h3>Running the Native Compiler With More Control</h3><p>One interesting thing about the TypeScript 7 native preview is that it gives you control over how much parallel work it does. For example, you can run:</p><pre>npx tsgo --noEmit --checkers 4</pre><p>This tells the compiler how many type-checking workers to use. For larger projects, this may help. For smaller machines or CI runners with limited memory, you may want to lower the number.</p><p>You can also run it in single-threaded mode:</p><pre>npx tsgo --noEmit --singleThreaded</pre><p>For monorepos using project references, there is also builder parallelization:</p><pre>npx tsgo -b --builders 2</pre><p>You can combine these options, but I would be careful:</p><pre>npx tsgo -b --checkers 4 --builders 4</pre><p>That may be faster, but it can also use more memory. I would not blindly increase every number just because it looks powerful. I would test it properly:</p><pre>time npx tsc --noEmit<br>time npx tsgo --noEmit<br>time npx tsgo --noEmit --checkers 2<br>time npx tsgo --noEmit --checkers 4</pre><h3>Final Thoughts</h3><p>TypeScript 7 feels like a big deal because it improves something we deal with every single day as developers: feedback speed. Faster type checking, a faster language service, and a native compiler may not sound flashy from the outside, but when you are inside a real project with frontend code, backend code, shared types, schemas, tests, and CI checks, these improvements can make the whole development experience feel lighter.</p><p>But I also think this is the important part: TypeScript 7 does not change the basics of writing good TypeScript. We still need clean types. We still need strict checks. We still need runtime validation at API boundaries. We still need readable tsconfig files. And we still need to be careful when trying preview tools in production projects. The native compiler is exciting, but I would treat it as something to test side by side first, not something to blindly switch on and hope everything works.</p><p>For me, the biggest takeaway is that TypeScript is becoming even more important for full-stack development. It is no longer just a nice extra layer on top of JavaScript. It is part of how we design safer APIs, cleaner frontend code, better shared contracts, and more maintainable applications. And with TypeScript 7 focusing so much on speed, it feels like the tooling is finally catching up to the size of the projects we are building today.</p><p>As always, I am writing these articles while learning and building in public, mostly to document the things that confused me, slowed me down, or helped me write better code. Hopefully this article gave you a practical way to think about TypeScript 7, how to try it safely, and how to use TypeScript more effectively in real full-stack projects.</p><p>If this helped you, please clap for the article, share it with another developer, and follow me for more practical full-stack and iOS/Android development articles. I will keep documenting the things I learn along the way so we can all avoid the same mistakes and build better projects together.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=843215898673" width="1" height="1" alt=""><hr><p><a href="https://levelup.gitconnected.com/i-tried-the-new-typescript-why-typescript-7-actually-feels-like-a-big-deal-843215898673">I Tried the New TypeScript: Why TypeScript 7 Actually Feels Like a Big Deal</a> was originally published in <a href="https://levelup.gitconnected.com/">Level Up Coding</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Delegates and Events in iOS: The Concept That Finally Made App Communication Click for Me]]></title>
            <link>https://levelup.gitconnected.com/delegates-and-events-in-ios-the-concept-that-finally-made-app-communication-click-for-me-28ab22b3202a?source=rss-85a120b73062------2</link>
            <guid isPermaLink="false">https://medium.com/p/28ab22b3202a</guid>
            <category><![CDATA[apple]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[ios]]></category>
            <category><![CDATA[mobile]]></category>
            <dc:creator><![CDATA[Sanjay Nelagadde]]></dc:creator>
            <pubDate>Mon, 29 Jun 2026 19:47:12 GMT</pubDate>
            <atom:updated>2026-06-29T19:47:12.034Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6J_Ak3VF8cSh_dc-Nb_EpA.png" /></figure><p>When I first started learning iOS development, delegates confused me more than I expected. The code itself did not look too hard. You create a protocol, add a delegate property, set it somewhere else, and call a method when something happens. But for a long time, I did not fully understand <em>why</em> this pattern existed or when I should use it instead of closures, NotificationCenter, SwiftUI state, or just calling another object directly.</p><p>That is one of the reasons I have been writing more iOS articles recently. I use these articles to track the issues I personally faced while building apps, especially the concepts that seemed simple at first but became more important as the app grew. I like documenting these lessons because if I struggled with something, there is a good chance someone else will run into the same confusion too. Hopefully, by sharing the mistakes and patterns that helped me, other developers can avoid repeating the same issues and get to the “aha” moment faster.</p><p>In some of my previous articles, I wrote about practical iOS topics from a similar angle. In <a href="https://levelup.gitconnected.com/swiftui-state-in-ios-a-practical-guide-993a70794cdd">SwiftUI State in iOS: A Practical Guide</a>, I talked about how state drives the UI and why understanding state makes SwiftUI much easier to reason about. In <a href="https://levelup.gitconnected.com/mvvm-in-swiftui-build-an-ios-app-that-stays-organized-as-it-grows-a16e64d9eb3a">MVVM in SwiftUI: Build an iOS App That Stays Organized as It Grows</a>, I focused on keeping code organized when an app starts becoming more than just a few screens. I also wrote about <a href="https://levelup.gitconnected.com/i-finally-tried-apple-visionkit-building-a-simple-document-scanner-in-swiftui-c272f0277546">trying Apple VisionKit and building a simple document scanner in SwiftUI</a>, which was a good reminder that even in SwiftUI, many Apple frameworks still rely heavily on delegate-based APIs.</p><p>This article is about delegates and events in iOS. More importantly, it is about how objects communicate with each other without everything becoming tightly coupled and messy.</p><h3>The Real Problem Delegates Solve</h3><p>Before talking about delegates, it helps to understand the problem they are solving. Imagine we have a screen that displays a list of notes. When the user taps a note, we want to open the detail screen. The easiest way is to make the list screen directly call the detail screen.</p><pre>final class NotesListViewController: UIViewController {<br><br>    private let detailViewController = NoteDetailViewController()<br><br>    func userDidTapNote(_ note: Note) {<br>        detailViewController.show(note)<br>    }<br>}</pre><p>This works, and honestly, in a very small app it might feel completely fine. But the problem is that NotesListViewControllernow knows too much. It knows who handles the selected note, how the next screen works, and what should happen after the tap. That is okay when the app has two screens, but it starts becoming painful when the app grows.</p><p>What if tapping a note sometimes opens a detail screen, sometimes triggers an edit flow, and sometimes just informs a parent coordinator? What if this list component should be reused somewhere else? What if we want to track analytics when a note is selected, but we do not want the list view controller to know anything about analytics? This is where delegates become useful because they allow an object to say, “Something happened here,” without needing to know exactly who is handling it or what happens next.</p><p>A delegate is basically a way for one object to communicate events to another object through a clearly defined contract. In Swift, that contract is usually a protocol.</p><pre>struct Note {<br>    let id: Int<br>    let title: String<br>}<br><br>protocol NotesListViewControllerDelegate: AnyObject {<br>    func notesListViewController(<br>        _ controller: NotesListViewController,<br>        didSelect note: Note<br>    )<br>}</pre><p>Now the list screen can expose a delegate property.</p><pre>final class NotesListViewController: UIViewController {<br><br>    weak var delegate: NotesListViewControllerDelegate?<br><br>    private let notes: [Note] = [<br>        Note(id: 1, title: &quot;SwiftUI State&quot;),<br>        Note(id: 2, title: &quot;MVVM&quot;),<br>        Note(id: 3, title: &quot;Delegates and Events&quot;)<br>    ]<br><br>    func userDidTapNote(at index: Int) {<br>        let selectedNote = notes[index]<br><br>        delegate?.notesListViewController(<br>            self,<br>            didSelect: selectedNote<br>        )<br>    }<br>}</pre><p>The important part here is that NotesListViewController does not decide what happens after the note is selected. It simply reports the event. Another object can become the delegate and decide what to do.</p><pre>final class NotesCoordinator: NotesListViewControllerDelegate {<br><br>    func notesListViewController(<br>        _ controller: NotesListViewController,<br>        didSelect note: Note<br>    ) {<br>        print(&quot;User selected note: \(note.title)&quot;)<br><br>        // Open detail screen<br>        // Track analytics<br>        // Update app state<br>        // Start another flow<br>    }<br>}</pre><p>This is the part that finally made delegates click for me. A delegate is not just extra syntax. It is a way to keep one object reusable and focused while letting another object handle the decision-making.</p><h3>Why Delegates Are Usually Weak</h3><p>One thing you will see a lot in delegate-based code is this:</p><pre>weak var delegate: NotesListViewControllerDelegate?</pre><p>At first, this can feel like one of those Swift details that people mention without explaining properly. But it matters because delegates often point back to an object that already owns the original object. For example, a parent view controller may own a child view controller, and then the child view controller may use the parent as its delegate.</p><pre>final class ParentViewController: UIViewController {<br><br>    private let notesListViewController = NotesListViewController()<br><br>    override func viewDidLoad() {<br>        super.viewDidLoad()<br><br>        notesListViewController.delegate = self<br>    }<br>}<br><br>extension ParentViewController: NotesListViewControllerDelegate {<br><br>    func notesListViewController(<br>        _ controller: NotesListViewController,<br>        didSelect note: Note<br>    ) {<br>        print(&quot;Selected note: \(note.title)&quot;)<br>    }<br>}</pre><p>If the parent strongly owns the child, and the child strongly owns the parent through the delegate, both objects can keep each other alive. That is called a retain cycle. To avoid this, delegate references are usually weak.</p><p>For a delegate to be weak, the protocol needs to be limited to class types. That is why we write AnyObject in the protocol declaration.</p><pre>protocol NotesListViewControllerDelegate: AnyObject {<br>    func notesListViewController(<br>        _ controller: NotesListViewController,<br>        didSelect note: Note<br>    )<br>}</pre><p>This is a small detail, but it is one of those details that becomes very important in real apps. If a view controller is not getting released from memory, a strong delegate reference is one of the first things I would check.</p><h3>A Practical UIKit Example</h3><p>Let’s use a more realistic example. Imagine we are building a reusable rating view. It shows buttons from 1 to 5, and when the user selects a rating, the parent screen needs to know. The rating view itself should not save the rating, call an API, show an alert, or navigate anywhere. Its job is only to display the buttons and report what the user selected.</p><p>First, we define the delegate protocol.</p><pre>protocol RatingViewDelegate: AnyObject {<br>    func ratingView(_ ratingView: RatingView, didSelectRating rating: Int)<br>}</pre><p>Now we can create the custom view.</p><pre>import UIKit<br><br>final class RatingView: UIView {<br><br>    weak var delegate: RatingViewDelegate?<br><br>    private let stackView = UIStackView()<br><br>    override init(frame: CGRect) {<br>        super.init(frame: frame)<br>        setupView()<br>        setupButtons()<br>    }<br><br>    required init?(coder: NSCoder) {<br>        super.init(coder: coder)<br>        setupView()<br>        setupButtons()<br>    }<br><br>    private func setupView() {<br>        stackView.axis = .horizontal<br>        stackView.spacing = 8<br>        stackView.distribution = .fillEqually<br><br>        addSubview(stackView)<br>        stackView.translatesAutoresizingMaskIntoConstraints = false<br><br>        NSLayoutConstraint.activate([<br>            stackView.leadingAnchor.constraint(equalTo: leadingAnchor),<br>            stackView.trailingAnchor.constraint(equalTo: trailingAnchor),<br>            stackView.topAnchor.constraint(equalTo: topAnchor),<br>            stackView.bottomAnchor.constraint(equalTo: bottomAnchor)<br>        ])<br>    }<br><br>    private func setupButtons() {<br>        for rating in 1...5 {<br>            let button = UIButton(type: .system)<br>            button.setTitle(&quot;\(rating)&quot;, for: .normal)<br>            button.tag = rating<br><br>            button.addTarget(<br>                self,<br>                action: #selector(ratingButtonTapped(_:)),<br>                for: .touchUpInside<br>            )<br><br>            stackView.addArrangedSubview(button)<br>        }<br>    }<br><br>    @objc private func ratingButtonTapped(_ sender: UIButton) {<br>        let rating = sender.tag<br>        delegate?.ratingView(self, didSelectRating: rating)<br>    }<br>}</pre><p>Then a view controller can use this rating view and decide what should happen when the rating is selected.</p><pre>final class ReviewViewController: UIViewController {<br><br>    private let ratingView = RatingView()<br><br>    override func viewDidLoad() {<br>        super.viewDidLoad()<br><br>        view.backgroundColor = .systemBackground<br>        ratingView.delegate = self<br><br>        view.addSubview(ratingView)<br>        ratingView.translatesAutoresizingMaskIntoConstraints = false<br><br>        NSLayoutConstraint.activate([<br>            ratingView.centerXAnchor.constraint(equalTo: view.centerXAnchor),<br>            ratingView.centerYAnchor.constraint(equalTo: view.centerYAnchor),<br>            ratingView.widthAnchor.constraint(equalToConstant: 250),<br>            ratingView.heightAnchor.constraint(equalToConstant: 44)<br>        ])<br>    }<br>}<br><br>extension ReviewViewController: RatingViewDelegate {<br><br>    func ratingView(_ ratingView: RatingView, didSelectRating rating: Int) {<br>        print(&quot;Selected rating: \(rating)&quot;)<br><br>        // Save rating<br>        // Update UI<br>        // Send event to ViewModel<br>        // Track analytics<br>    }<br>}</pre><p>This is a clean separation of responsibility. The RatingView only knows that a rating was tapped. The ReviewViewControllerdecides what that rating means in the context of the screen. This is the kind of separation that feels unnecessary in tiny examples, but becomes very valuable when you are building real screens that keep changing over time.</p><h3>Delegates and Events Are Related, But Not Exactly the Same</h3><p>An event is simply something that happened. A button was tapped. A text field changed. A scroll view moved. A network request finished. A document scanner completed scanning. A Bluetooth device disconnected. iOS apps are basically a long chain of events being handled in different ways.</p><p>Delegates are one way to handle events, but they are not the only way. UIKit and Swift give us several patterns, and this is where things can get confusing. You might see target-action for button taps, delegates for table views, closures for small callbacks, NotificationCenter for broadcasts, Combine publishers for streams, and SwiftUI modifiers like .onAppear and .onChange.</p><pre>// Target-action<br>button.addTarget(<br>    self,<br>    action: #selector(buttonTapped),<br>    for: .touchUpInside<br>)<br><br>// Delegate<br>tableView.delegate = self<br><br>// Closure callback<br>view.onRetryTapped = { [weak self] in<br>    self?.reloadData()<br>}<br><br>// NotificationCenter<br>NotificationCenter.default.post(<br>    name: .userDidLogout,<br>    object: nil<br>)<br><br>// SwiftUI event modifier<br>.onAppear {<br>    loadData()<br>}</pre><p>The trick is not to memorize every pattern as a separate thing. The better mental model is to ask: who needs to know that this happened, and how closely related are these objects? Once you answer that, the right pattern usually becomes easier to choose.</p><h3>Target-Action Is Great for Simple UI Events</h3><p>Target-action is very common in UIKit, especially with controls like UIButton, UISwitch, UISlider, and UITextField. If a user taps a button, you usually do not need a delegate protocol just for that one tap. Target-action is simple and direct.</p><pre>final class LoginViewController: UIViewController {<br><br>    private let loginButton = UIButton(type: .system)<br><br>    override func viewDidLoad() {<br>        super.viewDidLoad()<br><br>        loginButton.setTitle(&quot;Login&quot;, for: .normal)<br><br>        loginButton.addTarget(<br>            self,<br>            action: #selector(loginButtonTapped),<br>            for: .touchUpInside<br>        )<br>    }<br><br>    @objc private func loginButtonTapped() {<br>        print(&quot;Login button tapped&quot;)<br>    }<br>}</pre><p>This is perfect for simple UI interactions. The button sends an event, and the view controller handles it. You will commonly see events like .touchUpInside, .editingChanged, .valueChanged, and .primaryActionTriggered.</p><pre>textField.addTarget(<br>    self,<br>    action: #selector(textDidChange(_:)),<br>    for: .editingChanged<br>)<br><br>switchControl.addTarget(<br>    self,<br>    action: #selector(switchValueChanged(_:)),<br>    for: .valueChanged<br>)</pre><p>My rule is simple: if it is a direct UIKit control event, target-action is usually fine. I only start thinking about delegates or closures when I am building a custom component or when the communication becomes more structured.</p><h3>Delegates Are Better for Structured Communication</h3><p>Delegates are especially useful when one object needs to communicate multiple related events or decisions to another object. This is why UITableViewDelegate, UITableViewDataSource, UITextFieldDelegate, UIScrollViewDelegate, and many Apple framework APIs use delegates.</p><p>Take UITableView as an example. A table view does not only need to say “a row was tapped.” It may also need to ask how many rows exist, what each cell should look like, how tall a row should be, whether a row can be edited, and what should happen when a row is selected.</p><pre>final class NotesViewController: UIViewController {<br><br>    private let tableView = UITableView()<br>    private let notes = [&quot;SwiftUI State&quot;, &quot;MVVM&quot;, &quot;Delegates&quot;]<br><br>    override func viewDidLoad() {<br>        super.viewDidLoad()<br><br>        tableView.dataSource = self<br>        tableView.delegate = self<br>    }<br>}<br><br>extension NotesViewController: UITableViewDataSource {<br><br>    func tableView(<br>        _ tableView: UITableView,<br>        numberOfRowsInSection section: Int<br>    ) -&gt; Int {<br>        notes.count<br>    }<br><br>    func tableView(<br>        _ tableView: UITableView,<br>        cellForRowAt indexPath: IndexPath<br>    ) -&gt; UITableViewCell {<br>        let cell = UITableViewCell(style: .default, reuseIdentifier: nil)<br>        cell.textLabel?.text = notes[indexPath.row]<br>        return cell<br>    }<br>}<br><br>extension NotesViewController: UITableViewDelegate {<br><br>    func tableView(<br>        _ tableView: UITableView,<br>        didSelectRowAt indexPath: IndexPath<br>    ) {<br>        print(&quot;Selected note: \(notes[indexPath.row])&quot;)<br>    }<br>}</pre><p>This is where delegates shine. The table view has a clear relationship with the object that manages it. The delegate and data source define that relationship in a structured way. This is much cleaner than giving the table view many random closures for every possible behavior.</p><p>UITextFieldDelegate is another good example because the text field sometimes needs to ask questions before doing something.</p><pre>final class ProfileViewController: UIViewController {<br><br>    private let nameTextField = UITextField()<br><br>    override func viewDidLoad() {<br>        super.viewDidLoad()<br><br>        nameTextField.delegate = self<br>    }<br>}<br><br>extension ProfileViewController: UITextFieldDelegate {<br><br>    func textFieldShouldReturn(_ textField: UITextField) -&gt; Bool {<br>        textField.resignFirstResponder()<br>        return true<br>    }<br><br>    func textFieldDidEndEditing(_ textField: UITextField) {<br>        print(&quot;User entered name: \(textField.text ?? &quot;&quot;)&quot;)<br>    }<br>}</pre><p>This is not just an event callback. The text field is asking the delegate how it should behave. That is another reason delegates are useful: they are not only for reporting events, but also for letting another object customize behavior.</p><h3>Closures Are Great for Small Custom Events</h3><p>While delegates are useful, I do not think every custom view needs a delegate. Sometimes a closure is simpler and more readable. For example, imagine we have an empty state view with a retry button. If the only thing the view needs to report is “retry was tapped,” a closure works nicely.</p><pre>final class EmptyStateView: UIView {<br><br>    var onRetryTapped: (() -&gt; Void)?<br><br>    private let retryButton = UIButton(type: .system)<br><br>    override init(frame: CGRect) {<br>        super.init(frame: frame)<br>        setupButton()<br>    }<br><br>    required init?(coder: NSCoder) {<br>        super.init(coder: coder)<br>        setupButton()<br>    }<br><br>    private func setupButton() {<br>        retryButton.setTitle(&quot;Try Again&quot;, for: .normal)<br><br>        retryButton.addTarget(<br>            self,<br>            action: #selector(retryButtonTapped),<br>            for: .touchUpInside<br>        )<br><br>        addSubview(retryButton)<br>    }<br><br>    @objc private func retryButtonTapped() {<br>        onRetryTapped?()<br>    }<br>}</pre><p>Then the view controller can handle the callback like this:</p><pre>final class ErrorViewController: UIViewController {<br><br>    private let emptyStateView = EmptyStateView()<br><br>    override func viewDidLoad() {<br>        super.viewDidLoad()<br><br>        emptyStateView.onRetryTapped = { [weak self] in<br>            self?.reloadData()<br>        }<br>    }<br><br>    private func reloadData() {<br>        print(&quot;Reloading data...&quot;)<br>    }<br>}</pre><p>The important thing with closures is memory management. If a view owns a closure, and that closure strongly captures the view controller, you can accidentally create a retain cycle. That is why you often see [weak self] inside UIKit callbacks.</p><pre>emptyStateView.onRetryTapped = { [weak self] in<br>    self?.reloadData()<br>}</pre><p>My personal rule is that closures are great when there are one or two simple events. If the custom view starts exposing many callbacks, I usually move to a delegate because the API becomes easier to organize and understand.</p><h3>NotificationCenter Is for Broadcast Events</h3><p>NotificationCenter is another event pattern, but I try to use it carefully. It is useful when multiple unrelated parts of the app need to know about the same event. A common example is logout. When the user logs out, the profile screen may need to reset, the root screen may need to return to login, cached data may need to clear, and other parts of the app may need to react.</p><p>First, define a notification name.</p><pre>extension Notification.Name {<br>    static let userDidLogout = Notification.Name(&quot;userDidLogout&quot;)<br>}</pre><p>Then post the event when logout happens.</p><pre>final class SessionManager {<br><br>    func logout() {<br>        // Clear tokens<br>        // Reset user session<br>        // Remove cached data<br><br>        NotificationCenter.default.post(<br>            name: .userDidLogout,<br>            object: nil<br>        )<br>    }<br>}</pre><p>Any object that cares about logout can listen for it.</p><pre>final class HomeViewController: UIViewController {<br><br>    override func viewDidLoad() {<br>        super.viewDidLoad()<br><br>        NotificationCenter.default.addObserver(<br>            self,<br>            selector: #selector(handleLogout),<br>            name: .userDidLogout,<br>            object: nil<br>        )<br>    }<br><br>    @objc private func handleLogout() {<br>        print(&quot;User logged out. Return to login screen.&quot;)<br>    }<br><br>    deinit {<br>        NotificationCenter.default.removeObserver(self)<br>    }<br>}</pre><p>This is powerful, but it can also make the app harder to trace if overused. With delegates, you can usually see the relationship clearly. With notifications, the event is broadcast, and any object can listen from anywhere. That is useful for app-wide events, but I would not use it for every button tap or small component callback.</p><h3>SwiftUI Handles Events Differently</h3><p>In SwiftUI, we usually do not write delegates for basic view interactions. SwiftUI gives us event-style modifiers and closures instead. A button tap is just a closure.</p><pre>struct LoginView: View {<br><br>    var body: some View {<br>        Button(&quot;Login&quot;) {<br>            print(&quot;Login tapped&quot;)<br>        }<br>    }<br>}</pre><p>Text changes can be handled with .onChange.</p><pre>struct SearchView: View {<br><br>    @State private var searchText = &quot;&quot;<br><br>    var body: some View {<br>        TextField(&quot;Search&quot;, text: $searchText)<br>            .onChange(of: searchText) { oldValue, newValue in<br>                print(&quot;Search changed to: \(newValue)&quot;)<br>            }<br>    }<br>}</pre><p>View lifecycle events can be handled with .onAppear and .onDisappear.</p><pre>struct ProfileView: View {<br><br>    var body: some View {<br>        Text(&quot;Profile&quot;)<br>            .onAppear {<br>                print(&quot;Profile appeared&quot;)<br>            }<br>            .onDisappear {<br>                print(&quot;Profile disappeared&quot;)<br>            }<br>    }<br>}</pre><p>This connects nicely with the SwiftUI state idea. In UIKit, we often think, “When this event happens, update this label or push this screen.” In SwiftUI, we usually think, “When this event happens, update the state, and the UI will reflect that state.”</p><pre>struct LoginFormView: View {<br><br>    @State private var email = &quot;&quot;<br>    @State private var password = &quot;&quot;<br>    @State private var errorMessage: String?<br><br>    var body: some View {<br>        VStack(spacing: 12) {<br>            TextField(&quot;Email&quot;, text: $email)<br>                .textFieldStyle(.roundedBorder)<br><br>            SecureField(&quot;Password&quot;, text: $password)<br>                .textFieldStyle(.roundedBorder)<br><br>            Button(&quot;Login&quot;) {<br>                login()<br>            }<br><br>            if let errorMessage {<br>                Text(errorMessage)<br>                    .foregroundStyle(.red)<br>            }<br>        }<br>        .padding()<br>    }<br><br>    private func login() {<br>        guard !email.isEmpty else {<br>            errorMessage = &quot;Email is required&quot;<br>            return<br>        }<br><br>        guard !password.isEmpty else {<br>            errorMessage = &quot;Password is required&quot;<br>            return<br>        }<br><br>        errorMessage = nil<br>        print(&quot;Perform login&quot;)<br>    }<br>}</pre><p>Here, the button tap is the event, and errorMessage is the state. Once the state changes, SwiftUI updates the UI. This is why understanding events and state together is so helpful.</p><h3>Bridging UIKit Delegates Into SwiftUI</h3><p>Even if you are mainly writing SwiftUI, delegates still matter because many Apple frameworks are built around them. UIImagePickerController, VNDocumentCameraViewController, CLLocationManager, CBCentralManager, WKWebView, and many other APIs use delegates. This is exactly why delegates came back into the picture when I was working with VisionKit.</p><p>When you wrap UIKit inside SwiftUI, you usually use UIViewControllerRepresentable or UIViewRepresentable. Since UIKit expects a delegate object, SwiftUI gives us a Coordinator to bridge that delegate communication back into SwiftUI.</p><p>Here is an example using UIImagePickerController.</p><pre>import SwiftUI<br>import UIKit<br><br>struct ImagePicker: UIViewControllerRepresentable {<br><br>    @Binding var selectedImage: UIImage?<br>    @Environment(\.dismiss) private var dismiss<br><br>    func makeUIViewController(context: Context) -&gt; UIImagePickerController {<br>        let picker = UIImagePickerController()<br>        picker.delegate = context.coordinator<br>        picker.sourceType = .photoLibrary<br>        return picker<br>    }<br><br>    func updateUIViewController(<br>        _ uiViewController: UIImagePickerController,<br>        context: Context<br>    ) {<br>        // No update needed for this example<br>    }<br><br>    func makeCoordinator() -&gt; Coordinator {<br>        Coordinator(self)<br>    }<br><br>    final class Coordinator: NSObject,<br>                             UIImagePickerControllerDelegate,<br>                             UINavigationControllerDelegate {<br><br>        private let parent: ImagePicker<br><br>        init(_ parent: ImagePicker) {<br>            self.parent = parent<br>        }<br><br>        func imagePickerController(<br>            _ picker: UIImagePickerController,<br>            didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]<br>        ) {<br>            if let image = info[.originalImage] as? UIImage {<br>                parent.selectedImage = image<br>            }<br><br>            parent.dismiss()<br>        }<br><br>        func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {<br>            parent.dismiss()<br>        }<br>    }<br>}</pre><p>Now the SwiftUI view can use the image picker with a binding.</p><pre>struct ProfilePhotoView: View {<br><br>    @State private var selectedImage: UIImage?<br>    @State private var isShowingImagePicker = false<br><br>    var body: some View {<br>        VStack(spacing: 16) {<br>            if let selectedImage {<br>                Image(uiImage: selectedImage)<br>                    .resizable()<br>                    .scaledToFit()<br>                    .frame(height: 200)<br>            } else {<br>                Text(&quot;No image selected&quot;)<br>            }<br><br>            Button(&quot;Choose Photo&quot;) {<br>                isShowingImagePicker = true<br>            }<br>        }<br>        .sheet(isPresented: $isShowingImagePicker) {<br>            ImagePicker(selectedImage: $selectedImage)<br>        }<br>    }<br>}</pre><p>This pattern is very common. UIKit reports events through a delegate. The coordinator receives those delegate events. Then the coordinator updates SwiftUI state through a binding. Once I understood this flow, wrapping UIKit controllers in SwiftUI became much less mysterious.</p><h3>Delegate vs Closure: A Practical Decision</h3><p>Let’s say we are building a reusable login form view in UIKit. The form has an email field, password field, login button, and forgot password button. We could expose two closures like this.</p><pre>final class LoginFormView: UIView {<br><br>    var onLoginTapped: ((_ email: String, _ password: String) -&gt; Void)?<br>    var onForgotPasswordTapped: (() -&gt; Void)?<br><br>    private let emailTextField = UITextField()<br>    private let passwordTextField = UITextField()<br>    private let loginButton = UIButton(type: .system)<br>    private let forgotPasswordButton = UIButton(type: .system)<br><br>    override init(frame: CGRect) {<br>        super.init(frame: frame)<br>        setupActions()<br>    }<br><br>    required init?(coder: NSCoder) {<br>        super.init(coder: coder)<br>        setupActions()<br>    }<br><br>    private func setupActions() {<br>        loginButton.addTarget(<br>            self,<br>            action: #selector(loginButtonTapped),<br>            for: .touchUpInside<br>        )<br><br>        forgotPasswordButton.addTarget(<br>            self,<br>            action: #selector(forgotPasswordButtonTapped),<br>            for: .touchUpInside<br>        )<br>    }<br><br>    @objc private func loginButtonTapped() {<br>        onLoginTapped?(<br>            emailTextField.text ?? &quot;&quot;,<br>            passwordTextField.text ?? &quot;&quot;<br>        )<br>    }<br><br>    @objc private func forgotPasswordButtonTapped() {<br>        onForgotPasswordTapped?()<br>    }<br>}</pre><p>This is perfectly fine for a small component. But if the form starts growing with more related behavior, a delegate can make the API feel cleaner.</p><pre>protocol LoginFormViewDelegate: AnyObject {<br>    func loginFormView(<br>        _ view: LoginFormView,<br>        didTapLoginWithEmail email: String,<br>        password: String<br>    )<br><br>    func loginFormViewDidTapForgotPassword(_ view: LoginFormView)<br>}</pre><p>Then the form view can communicate through the delegate.</p><pre>final class LoginFormView: UIView {<br><br>    weak var delegate: LoginFormViewDelegate?<br><br>    private let emailTextField = UITextField()<br>    private let passwordTextField = UITextField()<br>    private let loginButton = UIButton(type: .system)<br>    private let forgotPasswordButton = UIButton(type: .system)<br><br>    override init(frame: CGRect) {<br>        super.init(frame: frame)<br>        setupActions()<br>    }<br><br>    required init?(coder: NSCoder) {<br>        super.init(coder: coder)<br>        setupActions()<br>    }<br><br>    private func setupActions() {<br>        loginButton.addTarget(<br>            self,<br>            action: #selector(loginButtonTapped),<br>            for: .touchUpInside<br>        )<br><br>        forgotPasswordButton.addTarget(<br>            self,<br>            action: #selector(forgotPasswordButtonTapped),<br>            for: .touchUpInside<br>        )<br>    }<br><br>    @objc private func loginButtonTapped() {<br>        delegate?.loginFormView(<br>            self,<br>            didTapLoginWithEmail: emailTextField.text ?? &quot;&quot;,<br>            password: passwordTextField.text ?? &quot;&quot;<br>        )<br>    }<br><br>    @objc private func forgotPasswordButtonTapped() {<br>        delegate?.loginFormViewDidTapForgotPassword(self)<br>    }<br>}</pre><p>And the view controller handles the behavior.</p><pre>final class LoginViewController: UIViewController {<br><br>    private let loginFormView = LoginFormView()<br>    private let viewModel = LoginViewModel()<br><br>    override func viewDidLoad() {<br>        super.viewDidLoad()<br><br>        loginFormView.delegate = self<br>    }<br>}<br><br>extension LoginViewController: LoginFormViewDelegate {<br><br>    func loginFormView(<br>        _ view: LoginFormView,<br>        didTapLoginWithEmail email: String,<br>        password: String<br>    ) {<br>        Task {<br>            do {<br>                try await viewModel.login(email: email, password: password)<br>                print(&quot;Navigate to home&quot;)<br>            } catch {<br>                print(&quot;Show error: \(error)&quot;)<br>            }<br>        }<br>    }<br><br>    func loginFormViewDidTapForgotPassword(_ view: LoginFormView) {<br>        print(&quot;Navigate to forgot password&quot;)<br>    }<br>}</pre><p>The view model can stay focused on business logic.</p><pre>final class LoginViewModel {<br><br>    func login(email: String, password: String) async throws {<br>        guard !email.isEmpty else {<br>            throw LoginError.missingEmail<br>        }<br><br>        guard !password.isEmpty else {<br>            throw LoginError.missingPassword<br>        }<br><br>        // Call login API here<br>    }<br>}<br><br>enum LoginError: Error {<br>    case missingEmail<br>    case missingPassword<br>}</pre><p>This connects directly with MVVM. The view reports the event, the view controller receives it, and the view model handles the logic. The login form does not need to know about networking, navigation, alerts, or authentication rules. That separation is what keeps the code easier to change later.</p><h3>Naming Delegate Methods Clearly</h3><p>One thing I appreciate more now is good delegate method naming. Apple’s APIs usually follow a pattern that makes the method read almost like a sentence.</p><pre>func tableView(<br>    _ tableView: UITableView,<br>    didSelectRowAt indexPath: IndexPath<br>)</pre><p>That method name tells us which object triggered the event, what happened, and what extra information is being passed. I try to follow the same style in my own code.</p><pre>func scannerViewController(<br>    _ controller: ScannerViewController,<br>    didFinishScanning pages: [UIImage]<br>)<br><br>func scannerViewControllerDidCancel(<br>    _ controller: ScannerViewController<br>)<br><br>func loginFormView(<br>    _ view: LoginFormView,<br>    didTapLoginWithEmail email: String,<br>    password: String<br>)</pre><p>This may look verbose, but it helps a lot when the codebase grows. A vague method like login() or done() may look cleaner today, but six months later you may not remember who called it, why it was called, or what context it belongs to. Clear delegate names make the code easier to scan and easier to debug.</p><h3>Common Mistakes I Try to Avoid Now</h3><p>The first mistake is forgetting to set the delegate. This is such a simple mistake, but it can waste a lot of time because the app compiles and runs, but nothing happens when the event is triggered.</p><pre>loginFormView.delegate = self</pre><p>The second mistake is forgetting to make the delegate weak. This can cause memory leaks, especially when a child object points back to its parent. If the delegate is a class-based relationship, I usually expect to see weak.</p><pre>weak var delegate: LoginFormViewDelegate?</pre><p>The third mistake is using delegates for everything. Not every event needs a protocol. If it is a simple button tap inside a view controller, target-action is enough. If it is one small custom callback, a closure may be cleaner. If it is a SwiftUI interaction, state and closures are usually the natural choice.</p><p>The fourth mistake is letting a view do too much. A custom view should usually report user actions, not decide the entire app flow. For example, I would avoid putting API calls, navigation, analytics, and alert handling directly inside a reusable form view.</p><pre>final class LoginFormView: UIView {<br><br>    @objc private func loginButtonTapped() {<br>        // This is too much responsibility for a reusable view.<br>        // Validate input<br>        // Call API<br>        // Navigate to home screen<br>        // Show alert<br>        // Track analytics<br>    }<br>}</pre><p>A cleaner approach is to let the view report the event and let another layer decide what to do.</p><pre>@objc private func loginButtonTapped() {<br>    delegate?.loginFormView(<br>        self,<br>        didTapLoginWithEmail: emailTextField.text ?? &quot;&quot;,<br>        password: passwordTextField.text ?? &quot;&quot;<br>    )<br>}</pre><p>This is a small shift in thinking, but it makes components much easier to reuse.</p><h3>The Mental Model I Use Now</h3><p>The easiest way I think about delegates and events is this: something happens in one object, and another object needs to know about it. That is really the core idea. The pattern you choose depends on the relationship between those objects.</p><p>If it is a simple UIKit control event, I use target-action. If it is one small custom callback, I usually use a closure. If it is a structured relationship with multiple related events, I use a delegate. If many unrelated parts of the app need to know about the same thing, I consider NotificationCenter. If I am in SwiftUI, I usually think in terms of closures, bindings, state, and modifiers like .onAppear, .onChange, and .task.</p><p>The question that helps me most is: “Who needs to know this happened?” If only the parent view needs to know, a closure or delegate may be enough. If the object needs to ask its owner multiple things, a delegate is probably better. If the whole app needs to react, a broadcast-style event may make sense.</p><h3>Final Thoughts</h3><p>Delegates can feel like an older iOS concept, especially when working with SwiftUI, async/await, and modern closure-based APIs. But they are still everywhere in iOS development. UIKit uses them heavily. Many Apple frameworks use them. SwiftUI wrappers often need coordinators because the UIKit controller underneath still communicates through delegates.</p><p>For me, the concept became much easier once I stopped thinking of delegates as just boilerplate. A delegate is simply a way for an object to say, “Something happened here, and I want someone else to decide what to do.” That is a powerful idea because it keeps objects focused, reusable, and less tightly connected.</p><p>I am continuing to write these iOS articles as a way to document the concepts and issues I run into while building apps. Some topics look simple in sample code, but they become much more meaningful when you start building real features. Delegates and events are exactly that kind of topic.</p><p>If this article helped you, please clap for it and follow me for more practical iOS development articles. It really helps and also motivates me to keep sharing what I learn while building.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=28ab22b3202a" width="1" height="1" alt=""><hr><p><a href="https://levelup.gitconnected.com/delegates-and-events-in-ios-the-concept-that-finally-made-app-communication-click-for-me-28ab22b3202a">Delegates and Events in iOS: The Concept That Finally Made App Communication Click for Me</a> was originally published in <a href="https://levelup.gitconnected.com/">Level Up Coding</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[I Finally Tried Apple VisionKit: Building a Simple Document Scanner in SwiftUI]]></title>
            <link>https://levelup.gitconnected.com/i-finally-tried-apple-visionkit-building-a-simple-document-scanner-in-swiftui-c272f0277546?source=rss-85a120b73062------2</link>
            <guid isPermaLink="false">https://medium.com/p/c272f0277546</guid>
            <category><![CDATA[swift]]></category>
            <category><![CDATA[apple]]></category>
            <category><![CDATA[ios]]></category>
            <category><![CDATA[mobile-app-development]]></category>
            <category><![CDATA[software-development]]></category>
            <dc:creator><![CDATA[Sanjay Nelagadde]]></dc:creator>
            <pubDate>Mon, 01 Jun 2026 15:53:39 GMT</pubDate>
            <atom:updated>2026-06-01T15:53:39.801Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Z5MutUjvS_YTU8NYMM8GBA.png" /></figure><p>I have been writing quite a bit about iOS development recently.</p><p>A while back, I wrote about SwiftUI state management in my article <a href="https://levelup.gitconnected.com/swiftui-state-in-ios-a-practical-guide-993a70794cdd">SwiftUI State in iOS: A Practical Guide</a>. After that, I wrote another article on <a href="https://levelup.gitconnected.com/mvvm-in-swiftui-build-an-ios-app-that-stays-organized-as-it-grows-a16e64d9eb3a">MVVM in SwiftUI</a>, where I talked about keeping an iOS app organized as it grows.</p><p>Both of those articles came from a very practical place. I was not trying to explain iOS development from a perfect textbook point of view. I was trying to explain the things that actually help when you are building real apps.</p><p>Recently, I heard about Apple’s VisionKit again and thought, “Why have I not properly tried this yet?”</p><p>I had used Apple’s Vision framework before in smaller ways, but VisionKit felt different. It is one of those frameworks that sounds advanced at first, but once you understand what it is trying to do, it becomes very practical.</p><p>You can use VisionKit to scan documents, detect text, scan QR codes, read barcodes, and work with camera-based data. In simple words, VisionKit helps your app understand useful information from the real world using the camera.</p><p>That immediately gave me an idea.</p><p>What if I build a simple SwiftUI app that lets the user scan a document, preview the scanned pages, and maybe later extract text from it?</p><p>That is what this article is about.</p><p>We are going to build a simple VisionKit-powered document scanner using SwiftUI.</p><h3>What Is VisionKit?</h3><p>VisionKit is an Apple framework that helps developers add camera-based scanning features to iOS apps.</p><p>Instead of building a full camera pipeline yourself, handling edge detection, document boundaries, perspective correction, and scanning UI, VisionKit gives you ready-made tools for common scanning use cases.</p><p>You can use VisionKit for things like:</p><ul><li>Scanning paper documents</li><li>Capturing receipts</li><li>Scanning handwritten notes</li><li>Detecting live text from the camera</li><li>Scanning QR codes and barcodes</li><li>Building lightweight scanner-style apps</li></ul><p>This is useful because many apps eventually need to deal with real-world information.</p><p>Maybe you are building an expense app and want to scan receipts.</p><p>Maybe you are building a notes app and want to scan handwritten notes.</p><p>Maybe you are building an inventory app and want to scan barcodes.</p><p>Or maybe you are like me and just curious enough to try the framework and see what it can do.</p><h3>\VisionKit vs Vision</h3><p>Before writing code, it helps to understand the difference between VisionKit and Vision.</p><p>The Vision framework is more focused on image analysis. It gives you APIs for things like text recognition, face detection, barcode detection, object tracking, and image classification.</p><p>VisionKit is more user-facing. It gives you ready-made UI components for scanning documents and data using the camera.</p><p>A simple way to think about it:</p><p>Vision is the engine.</p><p>VisionKit is the scanner experience.</p><p>For example, if you want to recognize text from an image manually, you may use Vision with VNRecognizeTextRequest.</p><p>But if you want to present a document scanner UI to the user, capture pages, and get scanned images back, VisionKit makes that much easier.</p><p>In this article, we will start with VisionKit document scanning.</p><h3>What We Are Building</h3><p>We will build a small SwiftUI app with the following features:</p><ul><li>A button to open the document scanner</li><li>A VisionKit wrapper for VNDocumentCameraViewController</li><li>A SwiftUI view to display scanned pages</li><li>A simple model to store scanned images</li><li>Optional text recognition using Vision</li></ul><p>This is not a full production scanner app yet. But it gives us the foundation.</p><p>Once this works, you can extend it into:</p><ul><li>A receipt scanner</li><li>A handwritten notes scanner</li><li>A PDF scanner</li><li>A business card scanner</li><li>A document archive app</li></ul><p>That is what I like about frameworks like this. You start with one simple demo, and suddenly you can see many product ideas around it.</p><h3>Step 1: Create a New SwiftUI Project</h3><p>Create a new iOS app in Xcode.</p><p>Use:</p><pre>Interface: SwiftUI<br>Language: Swift</pre><p>Then import VisionKit where needed:</p><pre>import VisionKit</pre><p>One important thing to remember is that VNDocumentCameraViewController is a UIKit controller. Since we are working in SwiftUI, we need to wrap it using UIViewControllerRepresentable.</p><p>This is a common pattern in SwiftUI when Apple gives us a UIKit API but we want to use it inside a SwiftUI app.</p><h3>Step 2: Add Camera Permission</h3><p>Because we are using the camera, we need to add camera permission in Info.plist.</p><p>Add this key:</p><pre>&lt;key&gt;NSCameraUsageDescription&lt;/key&gt;<br>&lt;string&gt;This app needs camera access to scan documents.&lt;/string&gt;</pre><p>Without this, your app will crash when trying to access the camera.</p><p>This is one of those small things that is easy to forget, especially when you are excited to test the scanner.</p><h3>Step 3: Create a Scanned Document Model</h3><p>Let us create a simple model to hold the scanned pages.</p><pre>import SwiftUI<br><br>struct ScannedDocument: Identifiable {<br>    let id = UUID()<br>    var title: String<br>    var pages: [UIImage]<br>    var createdAt: Date<br>}</pre><p>For now, we are keeping things simple.</p><p>Each scanned document has:</p><ul><li>A title</li><li>A list of scanned page images</li><li>A created date</li></ul><p>Later, you could add more fields like:</p><ul><li>Folder name</li><li>PDF URL</li><li>OCR text</li><li>Tags</li><li>Last updated date</li></ul><p>But for this article, images are enough.</p><h3>Step 4: Create the VisionKit Scanner Wrapper</h3><p>Now we need to wrap VNDocumentCameraViewController so it can be used in SwiftUI.</p><p>Create a new file called DocumentScannerView.swift.</p><pre>import SwiftUI<br>import VisionKit<br><br>struct DocumentScannerView: UIViewControllerRepresentable {<br>    var onScanComplete: ([UIImage]) -&gt; Void<br>    var onCancel: () -&gt; Void<br>    func makeUIViewController(context: Context) -&gt; VNDocumentCameraViewController {<br>        let scannerViewController = VNDocumentCameraViewController()<br>        scannerViewController.delegate = context.coordinator<br>        return scannerViewController<br>    }<br>    func updateUIViewController(<br>        _ uiViewController: VNDocumentCameraViewController,<br>        context: Context<br>    ) {<br>        // No update needed for this example<br>    }<br>    func makeCoordinator() -&gt; Coordinator {<br>        Coordinator(<br>            onScanComplete: onScanComplete,<br>            onCancel: onCancel<br>        )<br>    }<br>    final class Coordinator: NSObject, VNDocumentCameraViewControllerDelegate {<br>        private let onScanComplete: ([UIImage]) -&gt; Void<br>        private let onCancel: () -&gt; Void<br>        init(<br>            onScanComplete: @escaping ([UIImage]) -&gt; Void,<br>            onCancel: @escaping () -&gt; Void<br>        ) {<br>            self.onScanComplete = onScanComplete<br>            self.onCancel = onCancel<br>        }<br>        func documentCameraViewController(<br>            _ controller: VNDocumentCameraViewController,<br>            didFinishWith scan: VNDocumentCameraScan<br>        ) {<br>            var scannedPages: [UIImage] = []<br>            for pageIndex in 0..&lt;scan.pageCount {<br>                let image = scan.imageOfPage(at: pageIndex)<br>                scannedPages.append(image)<br>            }<br>            controller.dismiss(animated: true) {<br>                self.onScanComplete(scannedPages)<br>            }<br>        }<br>        func documentCameraViewControllerDidCancel(<br>            _ controller: VNDocumentCameraViewController<br>        ) {<br>            controller.dismiss(animated: true) {<br>                self.onCancel()<br>            }<br>        }<br>        func documentCameraViewController(<br>            _ controller: VNDocumentCameraViewController,<br>            didFailWithError error: Error<br>        ) {<br>            print(&quot;Document scanner failed: \(error.localizedDescription)&quot;)<br>            controller.dismiss(animated: true) {<br>                self.onCancel()<br>            }<br>        }<br>    }<br>}</pre><p>There is quite a bit happening here, so let us break it down.</p><p>DocumentScannerView conforms to UIViewControllerRepresentable. This is what allows us to use a UIKit view controller inside SwiftUI.</p><p>Inside makeUIViewController, we create a VNDocumentCameraViewController.</p><p>Then we assign its delegate to the coordinator.</p><p>The coordinator receives scanner events.</p><p>When scanning finishes, this method is called:</p><pre>func documentCameraViewController(<br>    _ controller: VNDocumentCameraViewController,<br>    didFinishWith scan: VNDocumentCameraScan<br>)</pre><p>The scan object contains all the scanned pages. We loop through each page and convert it into a UIImage.</p><pre>for pageIndex in 0..&lt;scan.pageCount {<br>    let image = scan.imageOfPage(at: pageIndex)<br>    scannedPages.append(image)<br>}</pre><p>Then we send those images back to SwiftUI using the onScanComplete closure.</p><p>This is one of my favorite parts of mixing UIKit and SwiftUI. UIKit still handles the scanner experience, but SwiftUI controls the app state.</p><h3>Step 5: Build the Main SwiftUI Screen</h3><p>Now let us create the main view.</p><pre>import SwiftUI<br><br>struct ContentView: View {<br>    @State private var documents: [ScannedDocument] = []<br>    @State private var isShowingScanner = false<br>    var body: some View {<br>        NavigationStack {<br>            VStack {<br>                if documents.isEmpty {<br>                    ContentUnavailableView(<br>                        &quot;No Scans Yet&quot;,<br>                        systemImage: &quot;doc.viewfinder&quot;,<br>                        description: Text(&quot;Tap Scan Document to create your first scan.&quot;)<br>                    )<br>                } else {<br>                    List {<br>                        ForEach(documents) { document in<br>                            NavigationLink {<br>                                ScanDetailView(document: document)<br>                            } label: {<br>                                VStack(alignment: .leading, spacing: 6) {<br>                                    Text(document.title)<br>                                        .font(.headline)<br>                                    Text(&quot;\(document.pages.count) page(s)&quot;)<br>                                        .font(.subheadline)<br>                                        .foregroundStyle(.secondary)<br>                                    Text(document.createdAt.formatted())<br>                                        .font(.caption)<br>                                        .foregroundStyle(.secondary)<br>                                }<br>                                .padding(.vertical, 4)<br>                            }<br>                        }<br>                    }<br>                }<br>            }<br>            .navigationTitle(&quot;VisionKit Scanner&quot;)<br>            .toolbar {<br>                ToolbarItem(placement: .topBarTrailing) {<br>                    Button {<br>                        isShowingScanner = true<br>                    } label: {<br>                        Label(&quot;Scan&quot;, systemImage: &quot;camera.viewfinder&quot;)<br>                    }<br>                }<br>            }<br>            .sheet(isPresented: $isShowingScanner) {<br>                DocumentScannerView(<br>                    onScanComplete: { images in<br>                        let newDocument = ScannedDocument(<br>                            title: &quot;Scan \(documents.count + 1)&quot;,<br>                            pages: images,<br>                            createdAt: Date()<br>                        )<br>                        documents.append(newDocument)<br>                        isShowingScanner = false<br>                    },<br>                    onCancel: {<br>                        isShowingScanner = false<br>                    }<br>                )<br>            }<br>        }<br>    }<br>}</pre><p>This gives us a simple scanner home screen.</p><p>If there are no scanned documents, we show an empty state.</p><p>If documents exist, we show them in a list.</p><p>When the user taps the scan button, we present the VisionKit scanner as a sheet.</p><pre>.sheet(isPresented: $isShowingScanner) {<br>    DocumentScannerView(<br>        onScanComplete: { images in<br>            let newDocument = ScannedDocument(<br>                title: &quot;Scan \(documents.count + 1)&quot;,<br>                pages: images,<br>                createdAt: Date()<br>            )<br><br>        documents.append(newDocument)<br>                    isShowingScanner = false<br>                },<br>                onCancel: {<br>                    isShowingScanner = false<br>                }<br>            )<br>        }</pre><p>This connects nicely with the SwiftUI state concepts I wrote about before.</p><p>The scanner produces images.</p><p>SwiftUI stores those images in @State.</p><p>The UI updates automatically.</p><p>This is exactly why understanding state is so important in SwiftUI. Frameworks like VisionKit give you the feature, but SwiftUI state decides how your app reacts to it.</p><h3>Step 6: Create a Detail View for Scanned Pages</h3><p>Now let us create a view to preview the scanned pages.</p><pre>import SwiftUI<br><br>struct ScanDetailView: View {<br>    let document: ScannedDocument<br>    var body: some View {<br>        ScrollView {<br>            LazyVStack(spacing: 20) {<br>                ForEach(Array(document.pages.enumerated()), id: \.offset) { index, image in<br>                    VStack(alignment: .leading, spacing: 8) {<br>                        Text(&quot;Page \(index + 1)&quot;)<br>                            .font(.headline)<br>                            .padding(.horizontal)<br>                        Image(uiImage: image)<br>                            .resizable()<br>                            .scaledToFit()<br>                            .clipShape(RoundedRectangle(cornerRadius: 12))<br>                            .shadow(radius: 4)<br>                            .padding(.horizontal)<br>                    }<br>                }<br>            }<br>            .padding(.vertical)<br>        }<br>        .navigationTitle(document.title)<br>        .navigationBarTitleDisplayMode(.inline)<br>    }<br>}</pre><p>Now when the user scans a document, they can tap it and see all scanned pages.</p><p>This is already a useful little app.</p><p>It does not save anything permanently yet. It does not generate a PDF yet. It does not run OCR yet.</p><p>But the core scanning flow works.</p><p>That is usually how I like to build features.</p><p>First, make the core path work.</p><p>Then improve it.</p><h3>Step 7: Add OCR Using Vision</h3><p>VisionKit helps us scan the document, but what if we want to extract text from the scanned page?</p><p>For that, we can use Apple’s Vision framework.</p><p>Create a new file called TextRecognizer.swift.</p><pre>import UIKit<br>import Vision<br><br>final class TextRecognizer {<br>    func recognizeText(from image: UIImage) async throws -&gt; String {<br>        guard let cgImage = image.cgImage else {<br>            return &quot;&quot;<br>        }<br>        return try await withCheckedThrowingContinuation { continuation in<br>            let request = VNRecognizeTextRequest { request, error in<br>                if let error {<br>                    continuation.resume(throwing: error)<br>                    return<br>                }<br>                guard let observations = request.results as? [VNRecognizedTextObservation] else {<br>                    continuation.resume(returning: &quot;&quot;)<br>                    return<br>                }<br>                let recognizedStrings = observations.compactMap { observation in<br>                    observation.topCandidates(1).first?.string<br>                }<br>                continuation.resume(returning: recognizedStrings.joined(separator: &quot;\n&quot;))<br>            }<br>            request.recognitionLevel = .accurate<br>            request.usesLanguageCorrection = true<br>            let handler = VNImageRequestHandler(cgImage: cgImage)<br>            do {<br>                try handler.perform([request])<br>            } catch {<br>                continuation.resume(throwing: error)<br>            }<br>        }<br>    }<br>}</pre><p>This class takes a UIImage and returns recognized text.</p><p>The important part is this request:</p><pre>let request = VNRecognizeTextRequest { request, error in<br>    // Handle OCR result<br>}</pre><p>Then we configure it:</p><pre>request.recognitionLevel = .accurate<br>request.usesLanguageCorrection = true</pre><p>For many document scanning use cases, .accurate is a good starting point. If you need faster recognition, you can explore other options later.</p><h3>Step 8: Show Recognized Text in the Detail View</h3><p>Now let us update our detail view so it can run OCR on the first page.</p><pre>import SwiftUI<br><br>struct ScanDetailView: View {<br>    let document: ScannedDocument<br>    @State private var recognizedText = &quot;&quot;<br>    @State private var isRecognizingText = false<br>    @State private var errorMessage: String?<br>    private let textRecognizer = TextRecognizer()<br>    var body: some View {<br>        ScrollView {<br>            LazyVStack(spacing: 20) {<br>                ForEach(Array(document.pages.enumerated()), id: \.offset) { index, image in<br>                    VStack(alignment: .leading, spacing: 8) {<br>                        Text(&quot;Page \(index + 1)&quot;)<br>                            .font(.headline)<br>                            .padding(.horizontal)<br>                        Image(uiImage: image)<br>                            .resizable()<br>                            .scaledToFit()<br>                            .clipShape(RoundedRectangle(cornerRadius: 12))<br>                            .shadow(radius: 4)<br>                            .padding(.horizontal)<br>                    }<br>                }<br>                Divider()<br>                    .padding(.horizontal)<br>                VStack(alignment: .leading, spacing: 12) {<br>                    Button {<br>                        Task {<br>                            await recognizeTextFromFirstPage()<br>                        }<br>                    } label: {<br>                        if isRecognizingText {<br>                            ProgressView()<br>                        } else {<br>                            Label(&quot;Recognize Text&quot;, systemImage: &quot;text.viewfinder&quot;)<br>                        }<br>                    }<br>                    .buttonStyle(.borderedProminent)<br>                    .disabled(isRecognizingText || document.pages.isEmpty)<br>                    if let errorMessage {<br>                        Text(errorMessage)<br>                            .foregroundStyle(.red)<br>                            .font(.footnote)<br>                    }<br>                    if !recognizedText.isEmpty {<br>                        Text(&quot;Recognized Text&quot;)<br>                            .font(.headline)<br>                        Text(recognizedText)<br>                            .font(.body)<br>                            .textSelection(.enabled)<br>                            .padding()<br>                            .frame(maxWidth: .infinity, alignment: .leading)<br>                            .background(Color(.secondarySystemBackground))<br>                            .clipShape(RoundedRectangle(cornerRadius: 12))<br>                    }<br>                }<br>                .padding(.horizontal)<br>            }<br>            .padding(.vertical)<br>        }<br>        .navigationTitle(document.title)<br>        .navigationBarTitleDisplayMode(.inline)<br>    }<br>    private func recognizeTextFromFirstPage() async {<br>        guard let firstPage = document.pages.first else {<br>            return<br>        }<br>        isRecognizingText = true<br>        errorMessage = nil<br>        do {<br>            recognizedText = try await textRecognizer.recognizeText(from: firstPage)<br>        } catch {<br>            errorMessage = &quot;Failed to recognize text: \(error.localizedDescription)&quot;<br>        }<br>        isRecognizingText = false<br>    }<br>}</pre><p>Now the app can scan a document and recognize text from the first page.</p><p>Again, this is not a production OCR pipeline yet, but it is enough to understand the flow.</p><p>The user scans a document.</p><p>VisionKit gives us images.</p><p>Vision extracts text from those images.</p><p>SwiftUI displays the result.</p><p>That is a pretty powerful combination.</p><h3>Step 9: Move Logic Toward MVVM</h3><p>In my MVVM article, I talked about why moving logic away from views helps as the app grows.</p><p>This scanner example is a perfect case where MVVM starts to make sense.</p><p>Right now, ContentView owns the scanned documents directly:</p><pre>@State private var documents: [ScannedDocument] = []</pre><p>That is fine for a small demo.</p><p>But if this app grows, we may want a view model.</p><p>Create a file called ScannerViewModel.swift.</p><pre>import SwiftUI<br><br>@MainActor<br>final class ScannerViewModel: ObservableObject {<br>    @Published private(set) var documents: [ScannedDocument] = []<br>    @Published var isShowingScanner = false<br>    func addScannedImages(_ images: [UIImage]) {<br>        let document = ScannedDocument(<br>            title: &quot;Scan \(documents.count + 1)&quot;,<br>            pages: images,<br>            createdAt: Date()<br>        )<br>        documents.append(document)<br>    }<br>    func showScanner() {<br>        isShowingScanner = true<br>    }<br>    func hideScanner() {<br>        isShowingScanner = false<br>    }<br>}</pre><p>Then update ContentView:</p><pre>import SwiftUI<br><br>struct ContentView: View {<br>    @StateObject private var viewModel = ScannerViewModel()<br>    var body: some View {<br>        NavigationStack {<br>            VStack {<br>                if viewModel.documents.isEmpty {<br>                    ContentUnavailableView(<br>                        &quot;No Scans Yet&quot;,<br>                        systemImage: &quot;doc.viewfinder&quot;,<br>                        description: Text(&quot;Tap Scan Document to create your first scan.&quot;)<br>                    )<br>                } else {<br>                    List {<br>                        ForEach(viewModel.documents) { document in<br>                            NavigationLink {<br>                                ScanDetailView(document: document)<br>                            } label: {<br>                                VStack(alignment: .leading, spacing: 6) {<br>                                    Text(document.title)<br>                                        .font(.headline)<br>                                    Text(&quot;\(document.pages.count) page(s)&quot;)<br>                                        .font(.subheadline)<br>                                        .foregroundStyle(.secondary)<br>                                    Text(document.createdAt.formatted())<br>                                        .font(.caption)<br>                                        .foregroundStyle(.secondary)<br>                                }<br>                                .padding(.vertical, 4)<br>                            }<br>                        }<br>                    }<br>                }<br>            }<br>            .navigationTitle(&quot;VisionKit Scanner&quot;)<br>            .toolbar {<br>                ToolbarItem(placement: .topBarTrailing) {<br>                    Button {<br>                        viewModel.showScanner()<br>                    } label: {<br>                        Label(&quot;Scan&quot;, systemImage: &quot;camera.viewfinder&quot;)<br>                    }<br>                }<br>            }<br>            .sheet(isPresented: $viewModel.isShowingScanner) {<br>                DocumentScannerView(<br>                    onScanComplete: { images in<br>                        viewModel.addScannedImages(images)<br>                        viewModel.hideScanner()<br>                    },<br>                    onCancel: {<br>                        viewModel.hideScanner()<br>                    }<br>                )<br>            }<br>        }<br>    }<br>}</pre><p>This version feels cleaner.</p><p>The view is mostly responsible for UI.</p><p>The view model handles scanner-related state.</p><p>This is where the concepts from SwiftUI state and MVVM start connecting with real frameworks.</p><p>It is one thing to learn @State, @StateObject, and ObservableObject in isolation. It is another thing to use them while integrating a real Apple framework like VisionKit.</p><h3>Step 10: What About Live Text and QR Scanning?</h3><p>So far, we used VisionKit for document scanning.</p><p>But VisionKit also has DataScannerViewController, which can scan live data from the camera.</p><p>This is useful when you want the user to point the camera at text, QR codes, barcodes, phone numbers, links, or other supported content.</p><p>A simple example looks like this:</p><pre>import SwiftUI<br>import VisionKit<br><br>struct LiveDataScannerView: UIViewControllerRepresentable {<br>    var onRecognizedText: (String) -&gt; Void<br>    func makeUIViewController(context: Context) -&gt; DataScannerViewController {<br>        let scanner = DataScannerViewController(<br>            recognizedDataTypes: [<br>                .text(languages: [&quot;en-US&quot;])<br>            ],<br>            qualityLevel: .balanced,<br>            recognizesMultipleItems: true,<br>            isHighFrameRateTrackingEnabled: true,<br>            isHighlightingEnabled: true<br>        )<br>        scanner.delegate = context.coordinator<br>        do {<br>            try scanner.startScanning()<br>        } catch {<br>            print(&quot;Failed to start scanning: \(error.localizedDescription)&quot;)<br>        }<br>        return scanner<br>    }<br>    func updateUIViewController(<br>        _ uiViewController: DataScannerViewController,<br>        context: Context<br>    ) {<br>        // No update needed for this example<br>    }<br>    func makeCoordinator() -&gt; Coordinator {<br>        Coordinator(onRecognizedText: onRecognizedText)<br>    }<br>    final class Coordinator: NSObject, DataScannerViewControllerDelegate {<br>        private let onRecognizedText: (String) -&gt; Void<br>        init(onRecognizedText: @escaping (String) -&gt; Void) {<br>            self.onRecognizedText = onRecognizedText<br>        }<br>        func dataScanner(<br>            _ dataScanner: DataScannerViewController,<br>            didTapOn item: RecognizedItem<br>        ) {<br>            switch item {<br>            case .text(let text):<br>                onRecognizedText(text.transcript)<br>            case .barcode(let barcode):<br>                if let payload = barcode.payloadStringValue {<br>                    onRecognizedText(payload)<br>                }<br>            @unknown default:<br>                break<br>            }<br>        }<br>    }<br>}</pre><p>This is a different experience from document scanning.</p><p>With document scanning, the user captures pages.</p><p>With live data scanning, the camera is continuously looking for useful information.</p><p>You could use this for:</p><ul><li>QR code scanning</li><li>Barcode scanning</li><li>Extracting a phone number from a business card</li><li>Detecting URLs</li><li>Reading labels</li><li>Scanning short pieces of text</li></ul><p>If you are building real-world iOS tools, this opens up a lot of possibilities.</p><h3>A Few Practical Notes</h3><p>There are a few things I would keep in mind before using VisionKit in a production app.</p><p>First, always test on a real device. Camera-based APIs are not something you should fully trust from the simulator.</p><p>Second, design for failure. The user may cancel scanning. Camera permission may be denied. Text recognition may fail. The scanned image may be blurry.</p><p>Third, keep the first version simple. It is very tempting to add PDF export, OCR, folders, cloud sync, search, tags, and sharing immediately. But the better approach is to first make scanning feel good.</p><p>Fourth, think about privacy. If you are scanning receipts, IDs, documents, or notes, users care about where that data goes. If everything stays local, say that clearly. If you upload files to a server, explain why and how.</p><h3>Where This Can Go Next</h3><p>This little demo can become much more useful.</p><p>The next features I would add are:</p><ul><li>Save scanned documents locally</li><li>Export scanned pages as PDF</li><li>Add OCR for all pages</li><li>Add search across recognized text</li><li>Add folders</li><li>Add rename and delete</li><li>Add share sheet support</li><li>Add basic image cleanup filters</li><li>Add iCloud sync later if needed</li></ul><p>This is how I usually like learning a new framework.</p><p>Not by reading every API first.</p><p>Not by trying to build a perfect app immediately.</p><p>But by building one small useful flow, then improving it piece by piece.</p><p>VisionKit is a good framework for that style of learning because you can get something working quickly, but there is still enough depth to build a real app from it.</p><p>I have also uploaded the complete sample project to GitHub here:<br><a href="https://github.com/sanjaynela/visionkit-scanner-ios">https://github.com/sanjaynela/visionkit-scanner-ios</a></p><p>Feel free to clone it, try it on a real iPhone, and modify it for your own scanner-style app. If you build something interesting with VisionKit, I would love to hear about it.</p><h3>Final Thoughts</h3><p>Trying VisionKit reminded me why I enjoy iOS development.</p><p>Apple gives us these powerful native frameworks, but the real learning happens when we connect them to actual app ideas.</p><p>A document scanner sounds simple at first. But once you build it, you start thinking about OCR, PDFs, local storage, search, folders, privacy, and user experience.</p><p>That is when a small demo becomes a real product idea.</p><p>If you already understand SwiftUI state and MVVM, VisionKit is a nice next framework to explore because it gives you something practical to build. You can use SwiftUI for the app structure, MVVM for organization, VisionKit for scanning, and Vision for text recognition.</p><p>That combination is powerful.</p><p>And honestly, this is the kind of framework I wish I had tried earlier.</p><p>Sometimes the best way to learn a new Apple framework is not to wait until you need it at work. It is to build a small personal app, break a few things, fix them, and slowly understand where the framework fits.</p><p>That is exactly what I wanted to do with VisionKit.</p><p>And now I can already see a few app ideas coming from it.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c272f0277546" width="1" height="1" alt=""><hr><p><a href="https://levelup.gitconnected.com/i-finally-tried-apple-visionkit-building-a-simple-document-scanner-in-swiftui-c272f0277546">I Finally Tried Apple VisionKit: Building a Simple Document Scanner in SwiftUI</a> was originally published in <a href="https://levelup.gitconnected.com/">Level Up Coding</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Scaling Navigation in Jetpack Compose: From Simple Apps to Real-World Architecture]]></title>
            <link>https://levelup.gitconnected.com/scaling-navigation-in-jetpack-compose-from-simple-apps-to-real-world-architecture-c6cf897f5a58?source=rss-85a120b73062------2</link>
            <guid isPermaLink="false">https://medium.com/p/c6cf897f5a58</guid>
            <category><![CDATA[jetpack-compose]]></category>
            <category><![CDATA[android]]></category>
            <category><![CDATA[apps]]></category>
            <category><![CDATA[mobile-app-development]]></category>
            <category><![CDATA[android-development]]></category>
            <dc:creator><![CDATA[Sanjay Nelagadde]]></dc:creator>
            <pubDate>Fri, 01 May 2026 20:30:17 GMT</pubDate>
            <atom:updated>2026-05-01T20:30:17.307Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Qu7bzQ-WMmFMyUtxdH4y5A.png" /></figure><p>A while back, I wrote an article on Jetpack Compose navigation where I walked through routes, arguments, nested graphs, and real-world flows. That article came from a very real place, just like my earlier SwiftUI pieces. I had revisited older code, noticed patterns that didn’t scale well, and wanted to document what finally clicked for me.</p><p>That article is still useful. In fact, I think it works well as a beginner-friendly guide or for apps with a handful of screens. If you have a home screen, a settings screen, maybe a details page, the approach there is more than enough.</p><p>But after working on larger apps, I realized something important.</p><p>That approach is not enough once your app grows.</p><p>It does not fully answer how to structure navigation when you have 20, 30, or 50 screens. It also does not address how navigation interacts with state, which is where most real-world complexity actually comes from.</p><p>So this article is the follow-up I wish I had written earlier.</p><p>This is not about learning the API. It is about how to structure navigation so it does not fall apart as your app grows.</p><h3>When navigation stops being simple</h3><p>Navigation looks easy in demos.</p><p>You create a NavHost, define a few composable destinations, and call navigate(). Everything works. You feel like you understand it.</p><p>Then the app starts becoming real.</p><p>You add login flows. You add onboarding. You introduce bottom tabs. You need to navigate after an API call succeeds. You need to clear the back stack after authentication. You need deep links. You have multiple features linking to each other.</p><p>And suddenly, navigation is not just moving between screens anymore. It becomes architecture.</p><p>That is the point where most apps start getting messy.</p><h3>The mental model still holds</h3><p>Even for large apps, the core mental model does not change.</p><ul><li>A route identifies a destination</li><li>NavHost defines the map</li><li>NavController moves through that map</li></ul><p>What changes is how you organize that map.</p><p>In small apps, a single NavHost with a few routes is fine.</p><p>In larger apps, that same approach turns into a giant file that is hard to reason about, hard to maintain, and easy to break.</p><h3>The mistake I made early on</h3><p>The biggest mistake I made when I first used Compose Navigation in a real project was this:</p><p>I kept everything in one place.</p><p>One NavHost. Dozens of composable() blocks. Routes defined inline. Navigation logic scattered across screens.</p><p>It worked, but it did not scale.</p><p>Over time:</p><ul><li>The file became too large to navigate mentally</li><li>Different flows started interfering with each other</li><li>Back stack behavior became harder to reason about</li><li>Refactoring became risky</li></ul><p>The fix was not learning new APIs. The fix was changing how I structured navigation.</p><h3>How to structure navigation in a real app</h3><p>If your app has many screens, the goal is not to centralize everything into one file.</p><p>The goal is to centralize ownership, while splitting structure.</p><p>A pattern that has worked well for me looks like this:</p><ul><li>One root NavHost</li><li>Separate navigation graphs per feature or flow</li><li>Centralized route definitions</li><li>Navigation handled at the route or container level</li></ul><p>In practice, that looks like this.</p><h3>Root navigation</h3><pre>@Composable<br>fun App() {<br>    val navController = rememberNavController()<br>    AppNavHost(navController)<br>}<br><br>@Composable<br>fun AppNavHost(navController: NavHostController) {<br>    NavHost(<br>        navController = navController,<br>        startDestination = Graph.AUTH,<br>        route = Graph.ROOT<br>    ) {<br>        authNavGraph(navController)<br>        mainNavGraph(navController)<br>    }<br>}</pre><p>The root graph only answers one question:</p><p>What are the major flows in the app?</p><p>Not every screen.</p><h3>Feature graphs</h3><p>Each flow gets its own navigation graph.</p><p>For example, authentication:</p><pre>fun NavGraphBuilder.authNavGraph(navController: NavHostController) {<br>    navigation(<br>        startDestination = &quot;login&quot;,<br>        route = Graph.AUTH<br>    ) {<br>        composable(&quot;login&quot;) {<br>            LoginScreen(<br>                onLoginSuccess = {<br>                    navController.navigate(Graph.MAIN) {<br>                        popUpTo(Graph.AUTH) { inclusive = true }<br>                    }<br>                }<br>            )<br>        }<br>composable(&quot;signup&quot;) {<br>            SignupScreen()<br>        }<br>    }<br>}</pre><p>And your main app flow:</p><pre>fun NavGraphBuilder.mainNavGraph(navController: NavHostController) {<br>    navigation(<br>        startDestination = &quot;home&quot;,<br>        route = Graph.MAIN<br>    ) {<br>        composable(&quot;home&quot;) {<br>            HomeRoute(navController)<br>        }<br>composable(&quot;settings&quot;) {<br>            SettingsScreen()<br>        }<br>    }<br>}</pre><p>This keeps related screens together and prevents your navigation from becoming one giant flat graph.</p><h3>Routes should still be structured</h3><p>Even in large apps, the rule from the previous article still applies.</p><p>Do not scatter route strings everywhere.</p><p>Use helpers.</p><pre>sealed class AppScreen(val route: String) {<br>    data object Home : AppScreen(&quot;home&quot;)<br>    data object UserDetails : AppScreen(&quot;user/{userId}&quot;) {<br>      fun createRoute(userId: Long) = &quot;user/$userId&quot;<br>    }<br>}</pre><p>This small habit saves you from a surprising number of bugs later.</p><h3>Where state actually lives (this is the missing piece)</h3><p>This is the part I did not cover well in my previous article. Navigation does not own your data. It only moves you between screens. In real apps, state should live in one of three places.</p><h3>1. Navigation arguments</h3><p>Only pass small, stable values:</p><ul><li>IDs n- filters</li><li>simple flags</li></ul><p>Example:</p><pre>navController.navigate(UserDetails.createRoute(userId))</pre><h3>2. ViewModel (screen state)</h3><p>Each screen should have a ViewModel that loads and holds business data.</p><pre>class UserDetailsViewModel(<br>    savedStateHandle: SavedStateHandle<br>) : ViewModel() {<br><br>private val userId: Long =<br>        checkNotNull(savedStateHandle[&quot;userId&quot;])<br>    val uiState = MutableStateFlow(UserDetailsUiState())<br>}</pre><p>This is where:</p><ul><li>API calls happen</li><li>loading and error states live</li><li>business logic is handled</li></ul><h3>3. Composable state</h3><p>UI-only state stays in the composable.</p><pre>var text by rememberSaveable { mutableStateOf(&quot;&quot;) }</pre><p>This includes:</p><ul><li>text fields</li><li>toggles</li><li>temporary UI selections</li></ul><h3>Why this separation matters</h3><p>If you try to push state through navigation, things break quickly.</p><ul><li>Objects become hard to serialize</li><li>Data becomes stale</li><li>Navigation becomes fragile</li></ul><p>Passing IDs and loading data in the destination keeps things predictable.</p><h3>Linking many screens cleanly</h3><p>When multiple screens link to each other, the temptation is to call NavController everywhere.</p><p>That works, but it spreads navigation logic across the UI.</p><p>A cleaner approach is to keep navigation at the route level.</p><pre>@Composable<br>fun HomeRoute(navController: NavHostController) {<br>    HomeScreen(<br>        onOpenSettings = {<br>            navController.navigate(&quot;settings&quot;)<br>        },<br>        onOpenUser = { id -&gt;<br>            navController.navigate(&quot;user/$id&quot;)<br>        }<br>    )<br>}<br><br>@Composable<br>fun HomeScreen(<br>    onOpenSettings: () -&gt; Unit,<br>    onOpenUser: (Long) -&gt; Unit<br>) {<br>    Column {<br>        Button(onClick = onOpenSettings) {<br>            Text(&quot;Settings&quot;)<br>        }<br>        Button(onClick = { onOpenUser(42) }) {<br>            Text(&quot;Open User&quot;)<br>        }<br>    }<br>}</pre><p>Now your UI stays reusable and easier to test.</p><h3>Bottom navigation and multiple flows</h3><p>For apps with tabs, navigation needs to handle multiple sections cleanly.</p><p>A common pattern is:</p><pre>navController.navigate(route) {<br>    popUpTo(navController.graph.startDestinationId) {<br>        saveState = true<br>    }<br>    launchSingleTop = true<br>    restoreState = true<br>}</pre><p>This:</p><ul><li>avoids duplicate screens</li><li>preserves tab state</li><li>restores state when returning to a tab</li></ul><p>This is one of those small details that makes a big difference in how polished your app feels.</p><h3>The structure that scales</h3><p>If I had to summarize the approach that works for large apps, it would be this:</p><ul><li>One root NavHost</li><li>Nested graphs per feature</li><li>Centralized route helpers</li><li>ViewModels for screen state</li><li>Composables focused on UI only</li></ul><p>This keeps navigation understandable even months later.</p><h3>Putting it all together (MainActivity + graphs + routes + state)</h3><p>To make this concrete, here’s a minimal but realistic wiring of everything together: a thin MainActivity, a root App, the AppNavHost, feature graphs, route helpers, and a screen with a ViewModel.</p><h3>MainActivity</h3><pre>class MainActivity : ComponentActivity() {<br>    override fun onCreate(savedInstanceState: Bundle?) {<br>        super.onCreate(savedInstanceState)<br>        setContent {<br>            App()<br>        }<br>    }<br>}</pre><h3>App entry</h3><pre>@Composable<br>fun App() {<br>    val navController = rememberNavController()<br>    AppNavHost(navController = navController)<br>}</pre><h3>Graph constants</h3><pre>object Graph {<br>    const val ROOT = &quot;root&quot;<br>    const val AUTH = &quot;auth&quot;<br>    const val MAIN = &quot;main&quot;<br>}</pre><h3>Route helpers</h3><pre>sealed interface Destination { val route: String }<br><br>sealed interface AuthDest : Destination {<br>    data object Login : AuthDest { override val route = &quot;login&quot; }<br>    data object Signup : AuthDest { override val route = &quot;signup&quot; }<br>}<br>sealed interface MainDest : Destination {<br>    data object Home : MainDest { override val route = &quot;home&quot; }<br>    data object Settings : MainDest { override val route = &quot;settings&quot; }<br>    data object UserDetails : MainDest {<br>        override val route = &quot;user/{userId}&quot;<br>        const val ARG = &quot;userId&quot;<br>        fun create(userId: Long) = &quot;user/$userId&quot;<br>    }<br>}</pre><h3>Root NavHost</h3><pre>@Composable<br>fun AppNavHost(navController: NavHostController) {<br>    NavHost(<br>        navController = navController,<br>        startDestination = Graph.AUTH,<br>        route = Graph.ROOT<br>    ) {<br>        authNavGraph(navController)<br>        mainNavGraph(navController)<br>    }<br>}</pre><h3>Auth graph</h3><pre>fun NavGraphBuilder.authNavGraph(navController: NavHostController) {<br>    navigation(startDestination = AuthDest.Login.route, route = Graph.AUTH) {<br>        composable(AuthDest.Login.route) {<br>            LoginRoute(<br>                onLoginSuccess = {<br>                    navController.navigate(Graph.MAIN) {<br>                        popUpTo(Graph.AUTH) { inclusive = true }<br>                    }<br>                },<br>                onSignup = { navController.navigate(AuthDest.Signup.route) }<br>            )<br>        }<br><br>composable(AuthDest.Signup.route) {<br>            SignupRoute(onDone = { navController.popBackStack() })<br>        }<br>    }<br>}</pre><h3>Main graph (with arguments)</h3><pre>fun NavGraphBuilder.mainNavGraph(navController: NavHostController) {<br>    navigation(startDestination = MainDest.Home.route, route = Graph.MAIN) {<br>        composable(MainDest.Home.route) {<br>            HomeRoute(<br>                onOpenSettings = { navController.navigate(MainDest.Settings.route) },<br>                onOpenUser = { id -&gt; navController.navigate(MainDest.UserDetails.create(id)) }<br>            )<br>        }<br>composable(MainDest.Settings.route) {<br>            SettingsRoute()<br>        }<br>        composable(<br>            route = MainDest.UserDetails.route,<br>            arguments = listOf(navArgument(MainDest.UserDetails.ARG) {<br>                type = NavType.LongType<br>            })<br>        ) { entry -&gt;<br>            val userId = entry.arguments?.getLong(MainDest.UserDetails.ARG)<br>                ?: return@composable<br>            UserDetailsRoute(userId = userId)<br>        }<br>    }<br>}</pre><h3>Route vs UI (keeping NavController out of leaf UI)</h3><pre>@Composable<br>fun HomeRoute(<br>    onOpenSettings: () -&gt; Unit,<br>    onOpenUser: (Long) -&gt; Unit<br>) {<br>    HomeScreen(<br>        onOpenSettings = onOpenSettings,<br>        onOpenUser = onOpenUser<br>    )<br>}<br><br>@Composable<br>fun HomeScreen(<br>    onOpenSettings: () -&gt; Unit,<br>    onOpenUser: (Long) -&gt; Unit<br>) {<br>    Column {<br>        Button(onClick = onOpenSettings) { Text(&quot;Settings&quot;) }<br>        Button(onClick = { onOpenUser(42L) }) { Text(&quot;Open User&quot;) }<br>    }<br>}</pre><h3>ViewModel-backed state on a destination</h3><pre>data class UserDetailsUiState(<br>    val isLoading: Boolean = true,<br>    val name: String = &quot;&quot;,<br>    val error: String? = null<br>)<br><br>class UserDetailsViewModel(<br>    savedStateHandle: SavedStateHandle<br>) : ViewModel() {<br>    private val userId: Long = checkNotNull(savedStateHandle[MainDest.UserDetails.ARG])<br>    private val _uiState = MutableStateFlow(UserDetailsUiState())<br>    val uiState: StateFlow&lt;UserDetailsUiState&gt; = _uiState<br>    init {<br>        // fake load<br>        viewModelScope.launch {<br>            delay(300)<br>            _uiState.value = UserDetailsUiState(isLoading = false, name = &quot;User #$userId&quot;)<br>        }<br>    }<br>}<br>@Composable<br>fun UserDetailsRoute(<br>    userId: Long,<br>    viewModel: UserDetailsViewModel = viewModel()<br>) {<br>    val uiState by viewModel.uiState.collectAsStateWithLifecycle()<br>    UserDetailsScreen(uiState = uiState)<br>}<br>@Composable<br>fun UserDetailsScreen(uiState: UserDetailsUiState) {<br>    when {<br>        uiState.isLoading -&gt; Text(&quot;Loading...&quot;)<br>        uiState.error != null -&gt; Text(&quot;Error: ${uiState.error}&quot;)<br>        else -&gt; Text(&quot;Hello ${uiState.name}&quot;)<br>    }<br>}</pre><p>This is the full loop:</p><ul><li>MainActivity hosts the app</li><li>App owns the NavController</li><li>AppNavHost defines the root graph</li><li>Feature graphs group related flows</li><li>Route helpers define destinations and arguments</li><li>Route composables handle navigation wiring</li><li>ViewModels own screen state</li><li>UI composables render state and emit events</li></ul><p>If you follow this structure, you can keep adding screens, flows, and features without your navigation turning into a giant, fragile file.</p><h3>Conclusion</h3><p>My previous article focused on helping Compose navigation click. This one is about making it scale.</p><p>For small apps, a single NavHost and a few routes are enough. But once your app grows, navigation is no longer just about moving between screens. It becomes part of your architecture. The biggest shift for me was realizing this:</p><p>Navigation should stay simple. Structure is what makes it powerful.</p><p>Once you separate graphs, routes, and state properly, everything becomes easier to reason about.</p><p>And more importantly, easier to maintain when your app inevitably grows.</p><p>If this helped, I would really appreciate a clap so more developers can find it, a follow if you want more practical mobile engineering write-ups, or a comment with how you are currently structuring navigation in your app.</p><p>That is usually where the most interesting discussions happen.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c6cf897f5a58" width="1" height="1" alt=""><hr><p><a href="https://levelup.gitconnected.com/scaling-navigation-in-jetpack-compose-from-simple-apps-to-real-world-architecture-c6cf897f5a58">Scaling Navigation in Jetpack Compose: From Simple Apps to Real-World Architecture</a> was originally published in <a href="https://levelup.gitconnected.com/">Level Up Coding</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[I Tried the New Codex App, and It Changed How I Think About Coding With AI]]></title>
            <link>https://levelup.gitconnected.com/i-tried-the-new-codex-app-and-it-changed-how-i-think-about-coding-with-ai-d87dd9437695?source=rss-85a120b73062------2</link>
            <guid isPermaLink="false">https://medium.com/p/d87dd9437695</guid>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[coding]]></category>
            <category><![CDATA[xcode]]></category>
            <category><![CDATA[software-development]]></category>
            <dc:creator><![CDATA[Sanjay Nelagadde]]></dc:creator>
            <pubDate>Fri, 01 May 2026 20:28:24 GMT</pubDate>
            <atom:updated>2026-05-01T20:28:24.935Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LcqUKfN3ca81YDKQ1DsJcg.png" /><figcaption>New Codex App</figcaption></figure><p>For the last few months, my AI coding workflow has been all over the place, but mostly in a good way. I have been using Cursor heavily, I have been using the Codex extension inside the IDE, and I have also been bouncing between Claude Code and Codex depending on the kind of work I am doing.</p><p>Claude Code became one of those tools I genuinely enjoyed because it made me think more clearly about planning, reusable workflows, skills, and how to make AI coding feel less random. I even wrote about that journey before in my Claude Code article, <a href="https://levelup.gitconnected.com/claude-code-skills-2-0-the-workflow-upgrade-that-made-claude-click-for-me-b41fc9a2b467">Claude Code Skills 2.0: The Workflow Upgrade That Made Claude Click for Me</a>.</p><p>I also recently wrote about Codex 5.4 after hearing that it could control the computer and getting curious enough to try it myself: <a href="https://medium.com/write-a-catalyst/i-heard-codex-5-4-could-control-the-computer-so-i-had-to-try-it-20308a623145">I Heard Codex 5.4 Could Control the Computer, So I Had to Try It</a>. So I was already familiar with Codex. At least I thought I was.</p><p>Until recently, my setup was still very IDE-centered. Cursor was my main editor, the Codex extension sat on the side, the terminal lived at the bottom, Git was somewhere nearby, and I had the usual developer mess of open tabs, half-read files, and temporary debugging thoughts scattered across the workspace.</p><p>That worked well for a while, but recently Cursor started feeling laggy for me. Not unusable, but enough that I started noticing the friction. Opening files felt slower, switching context felt heavier, and sometimes the editor felt like it was trying to do too many things at once.</p><p>So I did something I had not done seriously in a while: I moved back to Visual Studio Code. And honestly, VS Code felt refreshing again. It felt lighter, familiar, and predictable.</p><p>Then I realized something else. Codex had a dedicated app. Not just an extension, not just something inside ChatGPT, and not just a terminal workflow. A real Codex app.</p><p>So I tried it, and I was surprised by how quickly it clicked. At first, I assumed it would just be another wrapper around the same coding agent, but the experience felt different because it was not trying to be a full IDE in the traditional sense. It felt more like a focused workspace for supervising coding tasks.</p><p>That distinction became important once I started using it on an actual project instead of just poking around. The first thing that surprised me was simple: I could actually see the code files inside the Codex app. That may sound small, but it changed how I felt while using it.</p><p>With many AI coding tools, I sometimes feel like I am sending prompts into a black box. The assistant says it changed a file, then I jump back to the IDE, open the file, check the diff, run tests, inspect the changes, and decide whether I trust it. That workflow is fine, but it creates constant switching.</p><p>In the Codex app, I could stay inside the same place longer. I could ask Codex to make a change, inspect what it touched, review the diff, and continue the conversation without immediately jumping back into my editor.</p><p>For example, one of the first prompts I like using in a project is not a coding prompt at all. I might say:</p><pre>Review the authentication flow and explain where the app decides whether to show the login screen or the dashboard.</pre><pre>Do not change code yet. Just explain the current flow and mention the files involved.</pre><p>This is the kind of prompt I now use before making changes because it gives me confidence in the agent’s understanding. Instead of immediately asking the AI to “fix” something, I first ask it to map the codebase.</p><p>If it identifies the wrong files, I know I need to correct it before it starts editing. If it explains the flow correctly, then I can move forward. Codex came back with an explanation of the relevant files and logic, and I could inspect those files directly in the app.</p><p>That was the first moment where it did not feel like a chat assistant floating outside my codebase. It felt closer to a coding workspace.</p><h3>Why This Felt Different From an IDE Extension</h3><p>IDE extensions are great, and I still like them, but they usually live inside the mental model of an IDE. You open your editor, ask for a change, the assistant edits files, and you review everything from there.</p><p>The Codex app made me think differently. Instead of “I am coding and AI is helping me,” it felt more like “I am supervising coding tasks.” When I am actively implementing something myself, I still like an IDE because I want autocomplete, tabs, terminal, debugger, Git panel, and all the normal muscle memory.</p><p>But when I am exploring a codebase, planning a refactor, asking for a feature, reviewing changes, or running multiple ideas in parallel, the Codex app feels more natural. It changes the center of gravity from typing code to guiding work.</p><p>For example, instead of opening my IDE and manually jumping across files, I can start with something like:</p><pre>I want to add a settings screen where users can toggle dark mode.</pre><pre>First, inspect the current navigation and state management structure.<br>Then propose a plan.<br>Do not edit files yet.</pre><p>This is not really an “edit this line” task. It is a thinking task. And that is where the Codex app started to become useful for me.</p><p>One thing I have learned from using Claude Code and Codex is that I should not always start by asking the agent to code. That sounds obvious, but it is very easy to forget. When we see a coding agent, our first instinct is usually to say, “Build this feature.”</p><p>But for anything beyond a small change, I prefer plan mode first. My typical planning prompt looks like this:</p><pre>I want to add automatic retry logic when an API request fails due to a network issue.</pre><pre>Before writing code:<br>1. Inspect the current API client.<br>2. Find where errors are handled.<br>3. Suggest a minimal implementation plan.<br>4. Mention risks or edge cases.<br>5. Do not edit files yet.</pre><p>This kind of prompt gives me a much better result than immediately asking Codex to implement retry logic. I want to see how it understands the codebase before it starts editing.</p><p>If it identifies the wrong files, I can stop early. If it suggests a huge refactor for a simple task, I can push back. If it misses an important edge case, I can add that requirement before any code changes happen.</p><p>For me, plan mode is not about slowing down productivity. It is about avoiding messy productivity, and there is a big difference between the two.</p><h3>Why I Still Like Chat Mode for Ideas</h3><p>When I am just exploring ideas, I prefer chat mode. Plan mode is great when I already know what I want to build, but sometimes I do not. Sometimes I am just thinking through tradeoffs.</p><p>For example, I might say:</p><pre>I am thinking of adding a local cache for this app, but I am not sure if it should be SQLite, file-based JSON, or UserDefaults.</pre><pre>Look at the current project and help me think through the tradeoffs.</pre><p>That is not a coding task yet. That is a conversation. This is where I prefer chat mode because I want to go back and forth.</p><p>I want to ask whether the approach would make testing harder, whether there is a simpler first version, whether it becomes a problem if I add sync later, or how it compares to what I would do in a production app. I have noticed that when I separate thinking from coding, I get much better results.</p><p>Chat mode is where I explore, plan mode is where I structure, and implementation is where I execute. That small separation has made my AI coding workflow much cleaner.</p><h3>Reviewing Changes Is Where the App Really Helps</h3><p>One of my favorite parts of the Codex app is reviewing changes. I did not appreciate this enough until I started using it. When an AI coding tool modifies files, the quality of the experience depends heavily on how easy it is to review what changed.</p><p>If reviewing is painful, you either blindly trust the AI or waste time jumping between tools. Neither is good. In the Codex app, the review experience makes the workflow feel more complete because I can inspect file changes closer to the conversation that created them.</p><p>For example, if I ask Codex to refactor a user profile screen so network loading logic moves into a view model, I do not want to simply accept everything. I want to check whether it moved only the network logic, whether it accidentally changed the UI, whether it renamed things unnecessarily, and whether it added too much abstraction.</p><p>I also want to know whether tests still make sense and whether the pattern matches the rest of the app. That is why one of my personal rules now is simple: never accept big changes without reading the diff.</p><p>If Codex changes one small file, I can review quickly. But if it touches five, ten, or fifteen files, I slow down. I ask:</p><pre>Summarize the changes by file.<br>Mention why each file was changed.<br>Call out any behavior changes separately from refactoring changes.</pre><p>This gives me a clean review summary before I inspect the diff myself. Then I often ask:</p><pre>Are there any changes here that are not strictly required for the original task?</pre><p>That question is surprisingly useful because AI agents sometimes make “helpful” changes that are not really necessary. They might rename variables, move code around, add helper functions, or slightly restructure things because it looks cleaner.</p><p>Sometimes that is good, but sometimes it creates extra review burden. So I like to force Codex to separate required changes from optional cleanup. Another prompt I use is:</p><pre>Before I accept this, separate the changes into:<br>1. Required for the feature<br>2. Nice-to-have cleanup<br>3. Potentially risky changes</pre><p>This makes the review process feel much more controlled. It also reminds me that AI coding is not just about generating code. It is about safely accepting code.</p><p>The prompt matters, but the review matters even more.</p><h3>Git Inside Codex Is More Useful Than I Expected</h3><p>Another feature I did not expect to care about much was Git support. At first, I thought, “Okay, Git is Git. I can do that in VS Code or terminal.” But having Git actions close to the Codex workflow makes sense.</p><p>If Codex made the change, explained the change, and I reviewed the diff there, then committing from that same flow feels natural. The more useful part for me is not just committing. It is using Codex to help create better commits.</p><p>A lazy commit message looks like this:</p><pre>git commit -m &quot;fix stuff&quot;</pre><p>We have all done it. But with Codex, I can ask:</p><pre>Based on the diff, suggest a clear commit message.</pre><pre>Use this format:<br>&lt;type&gt;: &lt;short summary&gt;</pre><pre>Then include 3 bullet points explaining the main changes.</pre><p>For example, if I moved profile loading logic into a view model, a better commit message might be:</p><pre>refactor: move profile loading logic into view model</pre><pre>- Moved API request handling out of ProfileScreen<br>- Added ProfileViewModel state for loading, success, and error<br>- Preserved existing UI behavior while improving testability</pre><p>That is much better than “fix stuff.” If the change is small, I may use a single-line commit like:</p><pre>fix: prevent duplicate API retry after timeout</pre><p>If the change is larger, I prefer a commit body:</p><pre>feat: add settings screen for notification preferences</pre><pre>- Added SettingsScreen with notification toggle UI<br>- Wired settings route into the navigation graph<br>- Persisted notification preference locally<br>- Added basic state handling for initial preference loading</pre><p>This is one of those tiny habits that compounds over time. Good commit messages make future debugging easier. Bad commit messages make your future self angry.</p><p>My current Git workflow with Codex is becoming pretty simple. First, I ask Codex to inspect and plan. Then I approve the plan and let it code. Then I ask it to summarize the diff by file.</p><p>After that, I inspect the diff myself. Then I ask what I should manually test before committing. Finally, I ask for a commit message and either commit from the flow or open the project in VS Code if I want deeper control.</p><p>A typical sequence might look like this:</p><pre>Inspect the current project structure and propose the smallest safe change to add this feature. Do not edit files yet.</pre><p>Then:</p><pre>Implement the plan. Keep the change minimal. Do not refactor unrelated files.</pre><p>Then:</p><pre>Summarize the diff by file and call out any behavior changes.</pre><p>Then:</p><pre>What should I manually test before committing this?</pre><p>And finally:</p><pre>Write a commit message for this diff using conventional commit style.<br>Keep the subject under 72 characters.<br>Add a short bullet list for the body.</pre><p>That workflow feels clean because it keeps me in control. The Codex app does not need to replace the IDE completely. It can become the place where the agent does the work and I supervise the work.</p><p>That is a different workflow, and I like it more than I expected.</p><h3>Prompts I Keep Reusing</h3><p>If you are trying the Codex app, the best advice I can give is to start with exploration before implementation. Ask Codex to inspect the codebase and explain the relevant files before changing anything.</p><p>Use plan mode when the task has multiple steps, especially if you are touching architecture, navigation, state management, API logic, or persistence. Use chat mode when you are still thinking and comparing approaches.</p><p>Be strict about scope by saying things like “do not refactor unrelated files,” “keep this change minimal,” and “preserve existing behavior.” Review every diff because the diff is where you decide whether the work is actually good.</p><p>Ask for risk summaries, ask for manual testing steps, and use Codex to improve your commit messages. Here are a few prompts I find especially useful.</p><p>For understanding a new codebase, I use:</p><pre>Give me a guided tour of this codebase.</pre><pre>Focus on:<br>- Entry points<br>- Main modules<br>- State management<br>- API layer<br>- Where I should make changes for a new feature</pre><pre>Do not edit files.</pre><p>For making a safe change, I use:</p><pre>I want to add [feature].</pre><pre>Before editing:<br>1. Find the relevant files.<br>2. Explain the current behavior.<br>3. Suggest the smallest safe implementation.<br>4. Wait for my approval.</pre><p>For reducing unnecessary refactors, I use:</p><pre>Implement this with minimal changes.</pre><pre>Do not rename existing functions unless required.<br>Do not move files unless required.<br>Do not introduce a new architecture pattern.<br>Preserve existing behavior.</pre><p>For reviewing a diff, I use:</p><pre>Review your changes like a senior engineer.</pre><pre>Tell me:<br>- What changed<br>- Why it changed<br>- Any risks<br>- What I should test manually<br>- Whether any change is unrelated to the request</pre><p>For Git commit messages, I use:</p><pre>Generate a commit message from the current diff.</pre><pre>Use conventional commit style.<br>Keep the title short.<br>Add a body only if the change is large.<br>Do not exaggerate the change.</pre><p>The “do not exaggerate” part is funny, but useful. Sometimes AI-generated commit messages sound like the commit rebuilt the whole platform when it only changed one conditional.</p><h3>How I Decide Between Codex App, VS Code, Cursor, and Claude Code</h3><p>Right now, I do not think there is one perfect tool. I still like VS Code for hands-on coding because it is fast, familiar, and predictable. When I want to manually write code, debug something, or inspect multiple files quickly, VS Code feels natural.</p><p>I liked Cursor a lot too, and I may still use it, but recently the lag pushed me back to VS Code. I like Claude Code when I want a strong terminal-based workflow, especially around structured planning, reusable skills, and project-specific instructions.</p><p>Claude Code made me think more seriously about how to document workflows and make AI coding more repeatable. I like the Codex app when I want to supervise coding tasks, inspect files, review diffs, and manage agent work without feeling locked inside a traditional IDE.</p><p>That is the interesting shift for me. The IDE is no longer always the center of the coding workflow. Sometimes the agent workspace is.</p><p>Let’s say I have a simple app and I want to add a “recent notes” section. Old me might open the IDE, search files manually, find the home screen, inspect the data model, and start coding.</p><p>Now I might start in Codex app with:</p><pre>I want to add a Recent Notes section on the home screen.</pre><pre>First inspect the project and answer:<br>1. Where is the home screen implemented?<br>2. Where are notes loaded from?<br>3. Is there already a date field or updatedAt field?<br>4. What is the smallest safe implementation?<br>Do not edit files yet.</pre><p>After Codex responds, I might say:</p><pre>Good. Implement only the UI section and reuse existing note data.<br>Show the 5 most recently updated notes.<br>Do not add persistence changes.<br>Do not change navigation.</pre><p>After it edits files, I ask it to summarize the diff, tell me the edge cases, and suggest a commit message. That gives me a practical, reviewable flow instead of a random “AI changed a bunch of files, good luck” experience.</p><p>It is not magical, and it is not perfect, but it is clean.</p><h3>What I Still Watch Out For</h3><p>Even though I am enjoying the Codex app, I do not blindly trust it. That is probably the biggest advice I would give anyone using AI coding tools. AI can move fast, but fast does not always mean correct.</p><p>I still watch for over-engineering. If I ask for a small feature and the agent creates five new abstractions, I usually push back. I watch for silent behavior changes because a refactor that changes behavior is not just a refactor.</p><p>I watch for test gaps because if Codex changes core logic but does not update tests or at least tell me what to test, I ask follow-up questions. I also watch for Git hygiene because I do not want one giant commit containing a feature, formatting changes, unrelated cleanup, and dependency updates unless there is a very good reason.</p><p>The more I use tools like Codex and Claude Code, the more I feel my role changing slightly. I am still coding, but I am also planning more, reviewing more, writing better prompts, creating better constraints, and thinking more about architecture before implementation.</p><p>That is not a bad thing. In fact, it feels closer to how senior engineering work already happens. You do not just type code all day. You make decisions, review tradeoffs, protect the codebase from unnecessary complexity, and make sure the feature actually fits the product.</p><p>AI coding tools make it easier to generate code, but they also make it more important to know what good code should look like.</p><h3>Final Thoughts</h3><p>I did not expect the Codex app to change my workflow much. I thought it would be another interface around the same coding agent. But after using it, I get why the app exists.</p><p>The Codex app is not just about writing code. It is about managing coding work. It gives you a focused place to start tasks, inspect files, review changes, use Git, and guide the agent without constantly bouncing between chat, IDE, terminal, and browser.</p><p>For me, that matters. I still use VS Code. I still like Claude Code. I still think Cursor has done a lot to push AI coding forward.</p><p>But the Codex app made me realize that maybe the future coding workflow is not just “AI inside the IDE.” Maybe sometimes the IDE becomes one part of the workflow, and the agent command center becomes another.</p><p>That is the part I am excited about because when a tool helps me think, plan, review, and ship with less friction, I start enjoying the process more. Recently, that is exactly what happened with Codex.</p><p>I started enjoying it. And that is usually the first sign that a tool is going to stay in my workflow.</p><p>If you found this useful, consider clapping, following, or leaving a comment. I am currently experimenting a lot with Codex, Claude Code, VS Code, and AI coding workflows, so I will probably keep sharing what actually works for me in real projects.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=d87dd9437695" width="1" height="1" alt=""><hr><p><a href="https://levelup.gitconnected.com/i-tried-the-new-codex-app-and-it-changed-how-i-think-about-coding-with-ai-d87dd9437695">I Tried the New Codex App, and It Changed How I Think About Coding With AI</a> was originally published in <a href="https://levelup.gitconnected.com/">Level Up Coding</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Navigation in Android Jetpack Compose: A Practical Guide]]></title>
            <link>https://levelup.gitconnected.com/navigation-in-android-jetpack-compose-a-practical-guide-4d8037b07a87?source=rss-85a120b73062------2</link>
            <guid isPermaLink="false">https://medium.com/p/4d8037b07a87</guid>
            <category><![CDATA[android]]></category>
            <category><![CDATA[android-development]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[mobile]]></category>
            <category><![CDATA[jetpack-compose]]></category>
            <dc:creator><![CDATA[Sanjay Nelagadde]]></dc:creator>
            <pubDate>Mon, 13 Apr 2026 15:09:11 GMT</pubDate>
            <atom:updated>2026-04-13T15:09:11.508Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*P9_pNoeOXRpqfy6HN8uMTw.png" /></figure><h3>Routes, Arguments, Nested Graphs, and Real-World Flows (Without the Headaches)</h3><h3>Introduction</h3><p>A while back, I wrote <a href="https://medium.com/gitconnected/swiftui-navigation-in-ios-a-practical-guide-2a4820971681"><em>SwiftUI Navigation in iOS: A Practical Guide</em></a>. That article came from a very real place: I had gone back to some older iOS code and realized how much navigation logic I used to tolerate before the newer APIs finally made things click. Around the same time, I had a similar feeling while revisiting <a href="https://medium.com/gitconnected/swiftui-state-in-ios-a-practical-guide-993a70794cdd"><em>SwiftUI State in iOS: A Practical Guide</em></a>, because state and navigation usually start becoming painful at exactly the same moment: when an app stops being a toy project and starts behaving like a real one. Now I’ve had that same realization again, but this time on the Android side.</p><p>I’ve spent a lot of time building mobile apps across both iOS and Android, and one thing I’ve noticed is that navigation always looks easy in the sample app stage. You have a home screen, maybe a detail screen, maybe a settings page, and everything feels manageable. Then the app starts becoming real. You add login flows. You add onboarding. You introduce bottom tabs. You need to navigate after an API call succeeds. You need to clear the back stack after authentication. You add deep links. Suddenly, navigation stops feeling like a tiny implementation detail and starts feeling like architecture.</p><p>That’s usually when the confusion begins.</p><p>Not because Jetpack Compose navigation is bad, but because most of us first learn it through very small examples. And small examples don’t prepare you for the moment when your app has fifteen screens, three flows, and a product manager asking why the back button goes somewhere weird after sign-in.</p><p>That is exactly why I wanted to write this article.</p><p>I wanted something similar in spirit to my iOS navigation article, but for Android developers working with Jetpack Compose. Not a dry API reference. Not a toy sample that only works for two screens. I wanted a guide that feels like something you would actually refer to while building a real app.</p><p>So in this article, I’m going to walk through the mental model that helped Compose navigation finally feel predictable to me. We’ll start simple, then move into the stuff that matters in actual apps: typed-ish routes, passing arguments, programmatic navigation, clearing stacks, bottom bar navigation, nested graphs, and deep links.</p><p>If you are newer to Jetpack Compose, this should give you a solid foundation. And if you’ve already shipped a few screens and still feel like navigation is “working, but kind of messy,” this is for you too.</p><p>By the end, you’ll have:</p><ul><li>A clean mental model for how Compose navigation works.</li><li>Practical patterns for moving between screens without making your code harder to reason about.</li><li>Copy-pasteable examples for arguments, nested graphs, bottom navigation, and deep links.</li><li>A structure that scales much better once your app stops being a demo.</li></ul><h3>Context</h3><p>Before Jetpack Compose, navigation in Android often meant Fragments, XML navigation graphs, Safe Args, fragment transactions, and a lot of code spread across activities and fragment managers. It worked, but it also came with a lot of ceremony.</p><p>Then Compose arrived, and with it came a different way of thinking.</p><p>In Compose, your UI is already state-driven. Navigation fits best when you think about it the same way: your current destination is just another piece of app state.</p><p>That mental shift helped me a lot.</p><p>Because when I first touched Compose Navigation, I made the classic mistake of treating it like magic. I would copy a NavHost, throw in a few composable() blocks, call navController.navigate(...), and move on. For two or three screens, that feels fine. But once I started adding real flows, I noticed the same thing I saw years ago in mobile apps generally: navigation becomes hard when route definitions, arguments, and stack behavior are not intentional.</p><p>Here’s the mental model that made Compose navigation click for me:</p><p><strong>Your app has a navigation graph, your </strong><strong>NavController moves through that graph, and every screen is identified by a route.</strong></p><p>That’s it.</p><p>A route is just a string, but if you leave it as “just strings everywhere,” things can get messy fast. So the trick is to make those routes structured enough that your code stays readable.</p><p>In practical terms:</p><ul><li>NavHost is where you define the map of your screens.</li><li>NavController is what you use to move through that map.</li><li>A route identifies where you want to go.</li><li>Back stack behavior depends on how you call navigate() and whether you use options like popUpTo, launchSingleTop, and restoreState.</li></ul><p>Once you stop thinking of navigation as random jumps between screens and start thinking of it as movement through a graph with rules, the API starts feeling much less mysterious.</p><h3>Quick Start</h3><h3>The smallest Compose navigation setup that won’t haunt you later</h3><p>If you only remember one thing from this article, let it be this:</p><p><strong>Define your routes clearly, centralize them, and don’t scatter route strings across your app.</strong></p><p>Let’s start with a very small example.</p><h3>1) Basic setup with NavHost and a couple of routes</h3><pre>import androidx.compose.foundation.layout.Arrangement<br>import androidx.compose.foundation.layout.Column<br>import androidx.compose.foundation.layout.fillMaxSize<br>import androidx.compose.material3.Button<br>import androidx.compose.material3.MaterialTheme<br>import androidx.compose.material3.Text<br>import androidx.compose.runtime.Composable<br>import androidx.compose.ui.Alignment<br>import androidx.compose.ui.Modifier<br>import androidx.navigation.NavHostController<br>import androidx.navigation.compose.NavHost<br>import androidx.navigation.compose.composable<br>import androidx.navigation.compose.rememberNavController<br><br>sealed class AppScreen(val route: String) {<br>    data object Home : AppScreen(&quot;home&quot;)<br>    data object Settings : AppScreen(&quot;settings&quot;)<br>}<br>@Composable<br>fun AppNavHost(<br>    navController: NavHostController = rememberNavController()<br>) {<br>    NavHost(<br>        navController = navController,<br>        startDestination = AppScreen.Home.route<br>    ) {<br>        composable(AppScreen.Home.route) {<br>            HomeScreen(<br>                onOpenSettings = {<br>                    navController.navigate(AppScreen.Settings.route)<br>                }<br>            )<br>        }<br>        composable(AppScreen.Settings.route) {<br>            SettingsScreen()<br>        }<br>    }<br>}<br>@Composable<br>fun HomeScreen(onOpenSettings: () -&gt; Unit) {<br>    Column(<br>        modifier = Modifier.fillMaxSize(),<br>        verticalArrangement = Arrangement.Center,<br>        horizontalAlignment = Alignment.CenterHorizontally<br>    ) {<br>        Text(<br>            text = &quot;Home&quot;,<br>            style = MaterialTheme.typography.headlineMedium<br>        )<br>        Button(onClick = onOpenSettings) {<br>            Text(&quot;Open Settings&quot;)<br>        }<br>    }<br>}<br>@Composable<br>fun SettingsScreen() {<br>    Column(<br>        modifier = Modifier.fillMaxSize(),<br>        verticalArrangement = Arrangement.Center,<br>        horizontalAlignment = Alignment.CenterHorizontally<br>    ) {<br>        Text(<br>            text = &quot;Settings&quot;,<br>            style = MaterialTheme.typography.headlineMedium<br>        )<br>    }<br>}</pre><p>This example is intentionally small, but it already shows a few habits I strongly prefer in real projects.</p><p>First, routes are centralized in a sealed class instead of being hardcoded strings in random places. Second, the HomeScreendoes not directly know about NavController; it receives a callback. That may feel unnecessary in tiny examples, but it pays off later because your UI stays easier to preview, test, and reason about.</p><p>That second point matters more than it first appears.</p><p>One of the easiest ways to make Compose screens harder to maintain is to let every screen directly grab and manipulate the NavController. It works. I’ve done it too. But over time, it turns screens into coordination layers instead of UI layers.</p><p>Passing navigation lambdas keeps things cleaner.</p><h3>Why this structure is worth it</h3><p>Even at this early stage, this setup gives you a few nice benefits:</p><ul><li>Your routes live in one place.</li><li>Renaming screens becomes safer.</li><li>Screens stay more reusable because they don’t depend directly on navigation internals.</li><li>The NavHost becomes the source of truth for how screens are connected.</li></ul><p>This is very similar to what I like about typed routing on iOS. Compose doesn’t give you the exact same model out of the box, but you can still structure your routes in a way that makes your app feel more deliberate and less stringly-typed.</p><h3>Deep Dive</h3><h3>Passing arguments without turning your code into route-string soup</h3><p>The moment navigation starts getting interesting is usually when one screen needs data from another.</p><p>For example:</p><ul><li>Open a product details page for product 42.</li><li>Open a user profile for a given user ID.</li><li>Open an edit screen with an item ID.</li></ul><p>You can absolutely build routes manually with strings, but I strongly recommend wrapping that logic in helper functions so route construction stays consistent.</p><h3>2) Define a route with arguments</h3><pre>sealed class AppScreen(val route: String) {<br>    data object Home : AppScreen(&quot;home&quot;)<br>    data object Settings : AppScreen(&quot;settings&quot;)<br>    data object UserDetails : AppScreen(&quot;user/{userId}&quot;) {<br>        fun createRoute(userId: Int): String = &quot;user/$userId&quot;<br>    }<br>}</pre><p>Now let’s add the corresponding destination.</p><pre>import androidx.navigation.NavType<br>import androidx.navigation.navArgument<br><br>NavHost(<br>    navController = navController,<br>    startDestination = AppScreen.Home.route<br>) {<br>    composable(AppScreen.Home.route) {<br>        HomeScreen(<br>            onOpenSettings = {<br>                navController.navigate(AppScreen.Settings.route)<br>            },<br>            onOpenUser = { userId -&gt;<br>                navController.navigate(AppScreen.UserDetails.createRoute(userId))<br>            }<br>        )<br>    }<br>    composable(AppScreen.Settings.route) {<br>        SettingsScreen()<br>    }<br>    composable(<br>        route = AppScreen.UserDetails.route,<br>        arguments = listOf(<br>            navArgument(&quot;userId&quot;) {<br>                type = NavType.IntType<br>            }<br>        )<br>    ) { backStackEntry -&gt;<br>        val userId = backStackEntry.arguments?.getInt(&quot;userId&quot;) ?: return@composable<br>        UserDetailsScreen(userId = userId)<br>    }<br>}</pre><p>And update HomeScreen:</p><pre>@Composable<br>fun HomeScreen(<br>    onOpenSettings: () -&gt; Unit,<br>    onOpenUser: (Int) -&gt; Unit<br>) {<br>    Column(<br>        modifier = Modifier.fillMaxSize(),<br>        verticalArrangement = Arrangement.Center,<br>        horizontalAlignment = Alignment.CenterHorizontally<br>    ) {<br>        Text(<br>            text = &quot;Home&quot;,<br>            style = MaterialTheme.typography.headlineMedium<br>        )<br><br>Button(onClick = onOpenSettings) {<br>            Text(&quot;Open Settings&quot;)<br>        }<br>        Button(onClick = { onOpenUser(42) }) {<br>            Text(&quot;Open User 42&quot;)<br>        }<br>    }<br>}<br>@Composable<br>fun UserDetailsScreen(userId: Int) {<br>    Column(<br>        modifier = Modifier.fillMaxSize(),<br>        verticalArrangement = Arrangement.Center,<br>        horizontalAlignment = Alignment.CenterHorizontally<br>    ) {<br>        Text(<br>            text = &quot;User Details: $userId&quot;,<br>            style = MaterialTheme.typography.headlineMedium<br>        )<br>    }<br>}</pre><p>This is usually the point where Compose navigation starts making more sense to newer developers. A destination route can include placeholders, and when you navigate, you provide a concrete version of that route.</p><p>The big thing I’d emphasize here is this: <strong>never build argument routes ad hoc throughout the app.</strong></p><p>Do not write &quot;user/${id}&quot; in seven different files.</p><p>That is how bugs sneak in.</p><p>Put route creation next to the route definition. Your future self will thank you.</p><h3>Programmatic navigation and back stack control</h3><p>One of the things that makes navigation annoying in real apps is not moving forward. Forward is easy. The hard part is controlling what happens to the back stack.</p><p>This comes up all the time:</p><ul><li>After login, the user should not go back to the login screen.</li><li>After onboarding, you want to land in the main app and clear onboarding from history.</li><li>When reselecting a bottom tab, you may not want duplicate copies of the same destination.</li></ul><p>This is where navigate() options become very important.</p><h3>3) Navigate and clear part of the back stack</h3><pre>navController.navigate(&quot;main&quot;) {<br>    popUpTo(&quot;login&quot;) {<br>        inclusive = true<br>    }<br>}</pre><p>This says: navigate to main, and remove login from the back stack too.</p><p>That inclusive = true is the detail that makes the login screen disappear entirely from the back history.</p><p>If you leave it out, users may still be able to back their way into places you meant to leave behind.</p><h3>4) A small auth flow example</h3><pre>sealed class AppScreen(val route: String) {<br>    data object Login : AppScreen(&quot;login&quot;)<br>    data object Home : AppScreen(&quot;home&quot;)<br>    data object Settings : AppScreen(&quot;settings&quot;)<br>}<br><br>@Composable<br>fun AppNavHost(<br>    navController: NavHostController = rememberNavController(),<br>    isLoggedIn: Boolean = false<br>) {<br>    val startDestination = if (isLoggedIn) AppScreen.Home.route else AppScreen.Login.route<br>    NavHost(<br>        navController = navController,<br>        startDestination = startDestination<br>    ) {<br>        composable(AppScreen.Login.route) {<br>            LoginScreen(<br>                onLoginSuccess = {<br>                    navController.navigate(AppScreen.Home.route) {<br>                        popUpTo(AppScreen.Login.route) {<br>                            inclusive = true<br>                        }<br>                    }<br>                }<br>            )<br>        }<br>        composable(AppScreen.Home.route) {<br>            HomeScreen(<br>                onOpenSettings = {<br>                    navController.navigate(AppScreen.Settings.route)<br>                }<br>            )<br>        }<br>        composable(AppScreen.Settings.route) {<br>            SettingsScreen()<br>        }<br>    }<br>}<br>@Composable<br>fun LoginScreen(onLoginSuccess: () -&gt; Unit) {<br>    Column(<br>        modifier = Modifier.fillMaxSize(),<br>        verticalArrangement = Arrangement.Center,<br>        horizontalAlignment = Alignment.CenterHorizontally<br>    ) {<br>        Text(<br>            text = &quot;Login&quot;,<br>            style = MaterialTheme.typography.headlineMedium<br>        )<br>        Button(onClick = onLoginSuccess) {<br>            Text(&quot;Sign In&quot;)<br>        }<br>    }<br>}</pre><p>This is the kind of pattern I use a lot because it matches real product behavior. When auth succeeds, you do not want login hanging around in the back stack like a ghost from five seconds ago.</p><h3>Avoiding duplicate destinations</h3><p>Another navigation annoyance I hit early in Compose was duplicate destinations building up because I kept navigating to the same route repeatedly.</p><p>For example, if a user taps the same bottom nav item multiple times, you usually do not want five copies of the same screen.</p><p>That is where launchSingleTop helps.</p><pre>navController.navigate(AppScreen.Home.route) {<br>    launchSingleTop = true<br>}</pre><p>This tells the controller not to add another copy if the destination is already on top.</p><p>It sounds small, but it makes navigation feel much more stable in apps where users can repeatedly trigger the same route.</p><h3>Bottom navigation in Compose</h3><p>This is one of those topics that looks simple until you actually build it.</p><p>My first few Compose bottom bar implementations technically worked, but I always found myself tweaking them because back stack behavior was slightly off. The problem was never rendering the bar itself. The real challenge was making tab switching behave the way users expect.</p><p>Usually, users expect each tab to feel like its own section. They do not expect weird stack duplication. They also expect state restoration when they come back to a tab.</p><p>Here is a common and solid pattern.</p><h3>5) Bottom navigation with state restoration</h3><pre>import androidx.compose.material3.Icon<br>import androidx.compose.material3.NavigationBar<br>import androidx.compose.material3.NavigationBarItem<br>import androidx.compose.material3.Scaffold<br>import androidx.compose.runtime.getValue<br>import androidx.navigation.NavDestination.Companion.hierarchy<br>import androidx.navigation.compose.currentBackStackEntryAsState<br><br>sealed class BottomNavItem(<br>    val route: String,<br>    val label: String<br>) {<br>    data object Feed : BottomNavItem(&quot;feed&quot;, &quot;Feed&quot;)<br>    data object Search : BottomNavItem(&quot;search&quot;, &quot;Search&quot;)<br>    data object Profile : BottomNavItem(&quot;profile&quot;, &quot;Profile&quot;)<br>}<br>@Composable<br>fun MainScreen() {<br>    val navController = rememberNavController()<br>    val items = listOf(<br>        BottomNavItem.Feed,<br>        BottomNavItem.Search,<br>        BottomNavItem.Profile<br>    )<br>    Scaffold(<br>        bottomBar = {<br>            BottomBar(navController = navController, items = items)<br>        }<br>    ) { paddingValues -&gt;<br>        NavHost(<br>            navController = navController,<br>            startDestination = BottomNavItem.Feed.route<br>        ) {<br>            composable(BottomNavItem.Feed.route) {<br>                FeedScreen()<br>            }<br>            composable(BottomNavItem.Search.route) {<br>                SearchScreen()<br>            }<br>            composable(BottomNavItem.Profile.route) {<br>                ProfileScreen()<br>            }<br>        }<br>    }<br>}<br>@Composable<br>fun BottomBar(<br>    navController: NavHostController,<br>    items: List&lt;BottomNavItem&gt;<br>) {<br>    val navBackStackEntry by navController.currentBackStackEntryAsState()<br>    val currentDestination = navBackStackEntry?.destination<br>    NavigationBar {<br>        items.forEach { item -&gt;<br>            val selected = currentDestination?.hierarchy?.any { it.route == item.route } == true<br>            NavigationBarItem(<br>                selected = selected,<br>                onClick = {<br>                    navController.navigate(item.route) {<br>                        popUpTo(navController.graph.startDestinationId) {<br>                            saveState = true<br>                        }<br>                        launchSingleTop = true<br>                        restoreState = true<br>                    }<br>                },<br>                icon = {<br>                    Icon(<br>                        imageVector = androidx.compose.material.icons.Icons.Default.Home,<br>                        contentDescription = item.label<br>                    )<br>                },<br>                label = {<br>                    Text(item.label)<br>                }<br>            )<br>        }<br>    }<br>}</pre><p>A few things are happening here that are worth understanding.</p><ul><li>popUpTo(navController.graph.startDestinationId) prevents the back stack from growing in weird ways across tab switches.</li><li>saveState = true preserves prior tab state where possible.</li><li>restoreState = true restores it when the user returns.</li><li>launchSingleTop = true avoids duplicate copies of the same top destination.</li></ul><p>This combination is one of those things I wish someone had explained to me earlier, because it makes a very noticeable difference in how polished tab navigation feels.</p><h3>Nested graphs for flows that belong together</h3><p>Once your app grows, not every destination should sit flat in one giant NavHost block.</p><p>This is where nested graphs become extremely useful.</p><p>A very common example is authentication.</p><p>You might have:</p><ul><li>A login screen</li><li>A signup screen</li><li>A forgot password screen</li></ul><p>Those three screens belong to one flow. They are related. Grouping them under a nested graph makes your navigation structure easier to understand.</p><h3>6) Nested graph example</h3><pre>import androidx.navigation.navigation<br><br>object Graph {<br>    const val ROOT = &quot;root_graph&quot;<br>    const val AUTH = &quot;auth_graph&quot;<br>    const val MAIN = &quot;main_graph&quot;<br>}<br>sealed class AppScreen(val route: String) {<br>    data object Login : AppScreen(&quot;login&quot;)<br>    data object Signup : AppScreen(&quot;signup&quot;)<br>    data object ForgotPassword : AppScreen(&quot;forgot_password&quot;)<br>    data object Home : AppScreen(&quot;home&quot;)<br>    data object Settings : AppScreen(&quot;settings&quot;)<br>}<br>@Composable<br>fun AppNavHost(navController: NavHostController = rememberNavController()) {<br>    NavHost(<br>        navController = navController,<br>        route = Graph.ROOT,<br>        startDestination = Graph.AUTH<br>    ) {<br>        navigation(<br>            route = Graph.AUTH,<br>            startDestination = AppScreen.Login.route<br>        ) {<br>            composable(AppScreen.Login.route) {<br>                LoginScreen(<br>                    onLoginSuccess = {<br>                        navController.navigate(Graph.MAIN) {<br>                            popUpTo(Graph.AUTH) {<br>                                inclusive = true<br>                            }<br>                        }<br>                    },<br>                    onSignup = {<br>                        navController.navigate(AppScreen.Signup.route)<br>                    },<br>                    onForgotPassword = {<br>                        navController.navigate(AppScreen.ForgotPassword.route)<br>                    }<br>                )<br>            }<br>            composable(AppScreen.Signup.route) {<br>                SignupScreen()<br>            }<br>            composable(AppScreen.ForgotPassword.route) {<br>                ForgotPasswordScreen()<br>            }<br>        }<br>        navigation(<br>            route = Graph.MAIN,<br>            startDestination = AppScreen.Home.route<br>        ) {<br>            composable(AppScreen.Home.route) {<br>                HomeScreen(<br>                    onOpenSettings = {<br>                        navController.navigate(AppScreen.Settings.route)<br>                    }<br>                )<br>            }<br>            composable(AppScreen.Settings.route) {<br>                SettingsScreen()<br>            }<br>        }<br>    }<br>}</pre><p>This structure reads much more like the actual product.</p><p>There is an auth flow.<br>There is a main app flow.<br>They are separate sections of the graph.</p><p>That separation becomes really helpful once your app gets more complicated. It also makes transitions like “user signed in, leave auth entirely” much easier to express.</p><h3>Deep links in Compose</h3><p>Deep links are one of those features that sound fancy until you realize they are just another way of entering the graph.</p><p>A push notification might open a detail page.<br>A marketing link might open a specific screen.<br>An email verification link might route into a flow.</p><p>Compose Navigation supports this directly.</p><h3>7) Deep link example</h3><pre>import androidx.navigation.navDeepLink<br><br>sealed class AppScreen(val route: String) {<br>    data object Home : AppScreen(&quot;home&quot;)<br>    data object ArticleDetails : AppScreen(&quot;article/{articleId}&quot;) {<br>        fun createRoute(articleId: String): String = &quot;article/$articleId&quot;<br>    }<br>}<br>NavHost(<br>    navController = navController,<br>    startDestination = AppScreen.Home.route<br>) {<br>    composable(AppScreen.Home.route) {<br>        HomeScreen(<br>            onOpenArticle = { articleId -&gt;<br>                navController.navigate(AppScreen.ArticleDetails.createRoute(articleId))<br>            }<br>        )<br>    }<br>    composable(<br>        route = AppScreen.ArticleDetails.route,<br>        arguments = listOf(<br>            navArgument(&quot;articleId&quot;) { type = NavType.StringType }<br>        ),<br>        deepLinks = listOf(<br>            navDeepLink {<br>                uriPattern = &quot;myapp://article/{articleId}&quot;<br>            }<br>        )<br>    ) { backStackEntry -&gt;<br>        val articleId = backStackEntry.arguments?.getString(&quot;articleId&quot;) ?: return@composable<br>        ArticleDetailsScreen(articleId = articleId)<br>    }<br>}</pre><p>Now a URI like this can open the article details destination directly:</p><pre>myapp://article/abc123</pre><p>That is the nice part.</p><p>The important part is making sure your route and your deep link pattern stay aligned. If you are sloppy with naming or placeholders, deep links become much harder to maintain later.</p><p>This is another reason I like wrapping route creation in helper functions and centralizing destination definitions.</p><h3>Passing more complex data</h3><p>This is where I want to save people some pain.</p><p>When I first started with navigation in Android years ago, I had the same temptation many people do: “Why not just pass the full object?”</p><p>In Compose Navigation, you technically can serialize data and pass it around, but in most cases, I do not think that is the cleanest approach.</p><p>For real apps, I usually recommend this pattern instead:</p><ul><li>Pass a stable identifier through navigation.</li><li>Load the actual object from a repository or ViewModel on the destination side.</li></ul><p>So rather than passing a full User, pass userId.<br>Rather than passing a full Article, pass articleId.</p><p>Why?</p><p>Because routes are easier to reason about when they are small and stable. Also, once data gets large or changes over time, serializing whole objects into navigation starts feeling fragile.</p><p>If you stick to IDs for most navigation, your flow becomes more predictable.</p><h3>A small route helper pattern I really like</h3><p>Compose Navigation still uses string routes, so one thing I’ve found helpful is to make route definitions feel a little more structured.</p><p>Here is a simple pattern that scales well.</p><h3>8) Route helper objects</h3><pre>sealed interface AppDestination {<br>    val route: String<br>}<br><br>data object HomeDestination : AppDestination {<br>    override val route = &quot;home&quot;<br>}<br>data object SettingsDestination : AppDestination {<br>    override val route = &quot;settings&quot;<br>}<br>data object UserDestination : AppDestination {<br>    override val route = &quot;user/{userId}&quot;<br>    fun createRoute(userId: Int): String = &quot;user/$userId&quot;<br>    const val ARG_USER_ID = &quot;userId&quot;<br>}</pre><p>Then use those constants everywhere instead of raw strings.</p><pre>composable(<br>    route = UserDestination.route,<br>    arguments = listOf(<br>        navArgument(UserDestination.ARG_USER_ID) {<br>            type = NavType.IntType<br>        }<br>    )<br>) { backStackEntry -&gt;<br>    val userId = backStackEntry.arguments?.getInt(UserDestination.ARG_USER_ID)<br>        ?: return@composable<br><br>UserDetailsScreen(userId = userId)<br>}</pre><p>This does not magically make routes compile-time perfect the way fully typed navigation can in some ecosystems, but it does reduce accidental inconsistencies a lot.</p><h3>Keep NavController out of most leaf composables</h3><p>This is more of an architectural habit than a hard rule, but it has helped me repeatedly.</p><p>Try to keep NavController close to the graph or screen-container level, and pass simple lambdas into child composables.</p><p>For example, prefer this:</p><pre>@Composable<br>fun HomeRoute(navController: NavHostController) {<br>    HomeScreen(<br>        onOpenSettings = {<br>            navController.navigate(SettingsDestination.route)<br>        },<br>        onOpenUser = { userId -&gt;<br>            navController.navigate(UserDestination.createRoute(userId))<br>        }<br>    )<br>}</pre><p>And then keep the UI clean:</p><pre>@Composable<br>fun HomeScreen(<br>    onOpenSettings: () -&gt; Unit,<br>    onOpenUser: (Int) -&gt; Unit<br>) {<br>    Column {<br>        Button(onClick = onOpenSettings) {<br>            Text(&quot;Settings&quot;)<br>        }<br>Button(onClick = { onOpenUser(99) }) {<br>            Text(&quot;Open User&quot;)<br>        }<br>    }<br>}</pre><p>Instead of this in every leaf composable:</p><pre>@Composable<br>fun HomeScreen(navController: NavHostController) {<br>    Button(<br>        onClick = {<br>            navController.navigate(&quot;settings&quot;)<br>        }<br>    ) {<br>        Text(&quot;Settings&quot;)<br>    }<br>}</pre><p>The second version is tempting because it is quick, but it couples your UI directly to navigation internals. The first version stays easier to test, reuse, preview, and refactor.</p><h3>Common pitfalls I hit</h3><p>These are the exact kinds of potholes I’ve stepped into while working with Compose navigation.</p><h3>Pitfall 1: Hardcoding route strings everywhere</h3><p><strong>Mistake:</strong> Writing raw route strings in multiple files.</p><p><strong>Why it hurts:</strong> It becomes easy to mistype a route, change one place and forget another, or build arguments inconsistently.</p><p><strong>Fix:</strong> Centralize routes and route builders.</p><h3>Pitfall 2: Letting every composable own navigation</h3><p><strong>Mistake:</strong> Passing NavController deep into the UI tree.</p><p><strong>Why it hurts:</strong> Your composables become harder to reuse and test, and navigation logic gets scattered.</p><p><strong>Fix:</strong> Keep navigation near the graph or screen-container level and pass lambdas down.</p><h3>Pitfall 3: Forgetting back stack rules after auth or onboarding</h3><p><strong>Mistake:</strong> Navigating to the next screen but leaving old flow screens on the stack.</p><p><strong>Why it hurts:</strong> Users can hit back and end up in places that no longer make sense.</p><p><strong>Fix:</strong> Use popUpTo(...){ inclusive = true } intentionally.</p><h3>Pitfall 4: Duplicate destinations from repeated taps</h3><p><strong>Mistake:</strong> Navigating to the same route over and over without launchSingleTop.</p><p><strong>Why it hurts:</strong> Your stack gets cluttered and the app feels inconsistent.</p><p><strong>Fix:</strong> Use launchSingleTop = true where repeated navigation is likely.</p><h3>Pitfall 5: Passing entire objects through routes</h3><p><strong>Mistake:</strong> Treating navigation like a dump truck for full models.</p><p><strong>Why it hurts:</strong> It becomes fragile, noisy, and harder to evolve.</p><p><strong>Fix:</strong> Pass IDs or small stable values, then load the full data in the destination.</p><h3>Pitfall 6: One giant flat graph for everything</h3><p><strong>Mistake:</strong> Keeping auth, onboarding, tabs, and app content all at one level.</p><p><strong>Why it hurts:</strong> The graph becomes harder to reason about as the app grows.</p><p><strong>Fix:</strong> Use nested graphs for distinct flows.</p><h3>The mental model I’d keep</h3><p>If I had to compress this whole article into a few ideas, it would be these:</p><ul><li>A route identifies a destination.</li><li>NavHost defines the map.</li><li>NavController moves through that map.</li><li>Route structure matters more than people think.</li><li>Back stack behavior is part of navigation design, not an afterthought.</li><li>Nested graphs and route helpers make real apps much easier to maintain.</li></ul><p>That is the point where Compose navigation stopped feeling random to me.</p><p>It stopped being “call navigate and hope it works” and started feeling more like a system I could reason about.</p><h3>Conclusion</h3><p>If you are building Android apps with Jetpack Compose, this is the navigation approach I would recommend betting on.</p><p>Not because it is the fanciest possible setup, but because it stays understandable as your app grows.</p><p>For tiny apps, a simple NavHost with a few routes is enough. But once you add arguments, auth flows, bottom tabs, onboarding, or deep links, the difference between “it works” and “it scales” usually comes down to structure.</p><p>For me, the biggest shift was realizing that Compose navigation is not really about memorizing APIs. It is about being intentional with route definitions, graph boundaries, and back stack behavior.</p><p>Once that clicked, the code got easier to reason about.</p><p>And honestly, that is what I want from navigation more than anything else. I do not need it to feel clever. I need it to feel predictable, especially three months later when I revisit the project and wonder what past me was thinking.</p><p>If this article helped, I’d really appreciate a quick clap so more Android developers can find it, a follow if you want more practical mobile engineering write-ups, or a comment with the kind of navigation flow you are dealing with right now. Tabs, auth, onboarding, nested graphs, weird back-stack bugs, all of it. Those are usually the situations where the real learning happens.</p><p>Thanks for reading. This was one of those topics I wanted to write down properly because I keep seeing the same confusion show up in real projects, and honestly, I’ve hit most of these problems myself too.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=4d8037b07a87" width="1" height="1" alt=""><hr><p><a href="https://levelup.gitconnected.com/navigation-in-android-jetpack-compose-a-practical-guide-4d8037b07a87">Navigation in Android Jetpack Compose: A Practical Guide</a> was originally published in <a href="https://levelup.gitconnected.com/">Level Up Coding</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[I Tried Google Stitch for UI Design and It Changed How I Build Apps]]></title>
            <link>https://levelup.gitconnected.com/i-tried-google-stitch-for-ui-design-and-it-changed-how-i-build-apps-edd4c94ab097?source=rss-85a120b73062------2</link>
            <guid isPermaLink="false">https://medium.com/p/edd4c94ab097</guid>
            <category><![CDATA[mobile-app-development]]></category>
            <category><![CDATA[user-experience]]></category>
            <category><![CDATA[apple]]></category>
            <category><![CDATA[ui]]></category>
            <category><![CDATA[design]]></category>
            <dc:creator><![CDATA[Sanjay Nelagadde]]></dc:creator>
            <pubDate>Mon, 30 Mar 2026 20:35:34 GMT</pubDate>
            <atom:updated>2026-03-30T20:35:34.018Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Uf4Pfjesbz-DeAnRx5LUeA.png" /><figcaption>Image by author</figcaption></figure><p>I’ve spent most of my career building mobile apps. SwiftUI on one side, Jetpack Compose on the other, and somewhere in between, a lot of time spent fighting tiny UI details that no one outside development ever notices. Spacing. Alignment. State updates. Navigation edge cases. Things that seem small until they eat half your day.</p><p>But recently, I decided to switch gears for a bit.</p><p>I’ve been trying to build more frontend applications and intentionally taking a small break from iOS and Android work. Not because I’m bored of mobile, but because I wanted to explore a different kind of speed. The web, especially right now, feels like the most interesting place to experiment if you’re into AI-assisted development. You can go from idea to something visible incredibly fast, and that has been surprisingly refreshing.</p><p>That curiosity led me into a bunch of experiments with AI coding tools, and I wrote about one of them recently in this article:</p><p><strong>I Let Codex Design and Test a Frontend App for Me. Here’s What Actually Happened</strong><br><a href="https://levelup.gitconnected.com/i-let-codex-design-and-test-a-frontend-app-for-me-heres-what-actually-happened-a192b514f27e">https://levelup.gitconnected.com/i-let-codex-design-and-test-a-frontend-app-for-me-heres-what-actually-happened-a192b514f27e</a></p><p>That experiment taught me something important: generating code is no longer the hardest part.</p><p>The real bottleneck is often the step <em>before</em> code.</p><p>It’s figuring out what the UI should even look like.</p><p>That blank-canvas moment is still where a lot of momentum dies. Even if you’re good at building apps, there’s still a lot of friction between “I have an idea” and “I have something I can actually implement.” You’re thinking about layout, hierarchy, sections, user flow, component structure, and whether the screen in your head will even make sense once it becomes real.</p><p>That’s exactly why Google Stitch caught my attention.</p><p>When I first heard about it, my reaction was honestly a mix of skepticism and curiosity. A tool that can generate UI from prompts sounds like one of those things that is either surprisingly useful or completely gimmicky. There usually isn’t much middle ground.</p><p>So I tried it.</p><p>And after spending time with it, I realized this is one of those tools that becomes much more interesting once you stop thinking of it as a “design toy” and start using it as part of a real implementation workflow.</p><h3>What Google Stitch Actually Is</h3><p>The simplest way to think about Google Stitch is this: it takes a text prompt and turns it into a structured UI concept. Instead of opening a design tool and manually arranging sections, cards, charts, buttons, tabs, and layouts, you describe the kind of interface you want, and Stitch generates a visual direction for you.</p><p>That alone is useful. But what makes it more interesting for developers is not just that it can create something pretty. It’s that it can help remove the “what should this screen look like?” phase that often slows down implementation.</p><p>And if you’ve been using AI tools for coding already, this matters a lot.</p><p>Because once you already have a reasonable visual structure, it becomes much easier to take that and move into actual code generation.</p><p>That’s where Stitch clicked for me.</p><p>Not as a replacement for design. Not as a magical one-click app builder. But as a practical starting point that can save you from wasting energy on the first 20% of the UI process.</p><h3>Why I Wanted to Try It</h3><p>At this point, I’m already using tools like Codex, Cursor, and Claude Code in different parts of my workflow. They’ve all helped me move faster in different ways, especially when it comes to planning, scaffolding, debugging, and implementation.</p><p>But even with all of that, I still noticed something weird.</p><p>I could generate backend logic quickly.<br>I could scaffold frontend code quickly.<br>I could even ask AI to build components for me.</p><p>And yet, I still kept slowing down at the exact same place.</p><p>The visual structure.</p><p>Not because writing UI code is hard for me. It’s not. I’ve written enough UI in SwiftUI and Compose to feel very comfortable there. But frontend UI still has a different kind of decision fatigue. When you start from nothing, you have to make dozens of small visual decisions before the app even starts to feel real.</p><p>How should the dashboard be split?<br>Should stats go at the top or middle?<br>Should this be a card, a chart, a tab, or a side panel?<br>What would make this screen feel modern instead of generic?</p><p>That’s the kind of friction I wanted to reduce.</p><p>So instead of asking, “Can Stitch design my app for me?” I approached it with a much more useful question:</p><p><strong>Can Stitch help me get to implementation faster?</strong></p><p>That turned out to be the right question.</p><h3>My First Real Test: A Fitness Dashboard</h3><p>I wanted to start with something simple enough to evaluate quickly but realistic enough to feel like a real product screen. So I gave Stitch a prompt around a fitness dashboard.</p><p>This was the prompt:</p><pre>Create a modern dashboard UI for a fitness app with:<br>- Daily activity summary<br>- Heart rate chart<br>- Sleep tracking card<br>- Recent workouts section<br>- Bottom navigation bar<br>Use a clean, premium mobile app style.</pre><p>What I liked immediately was that this forced me to think like a product person instead of jumping straight into code. Instead of saying “build me a screen,” I had to describe what the screen should actually <em>do</em> and <em>contain</em>.</p><p>And that alone is underrated.</p><p>A lot of bad UI starts because we jump into implementation before we’ve even clearly described the screen.</p><p>Stitch came back with a layout that, while not perfect, was genuinely useful. It gave me a dashboard structure with a top summary area, visual cards, a chart section, and enough hierarchy to understand where everything should live.</p><p>Was it production-ready? No.</p><p>Did it immediately save me from staring at an empty screen and making every design decision manually? Absolutely.</p><p>That was the first moment where I thought, okay, this is actually practical.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*NF0_2nKvFqLQPBDHWvCU2w.png" /><figcaption>Screenshot of fitness dashboard in Stitch</figcaption></figure><h3>Why This Was More Useful Than It Looks</h3><p>At first glance, a generated UI might seem like a novelty. Something fun to play with for a few minutes and then forget.</p><p>But that’s not how I ended up using it.</p><p>The value wasn’t in whether Stitch gave me the exact final screen I wanted. The value was that it gave me a <strong>strong enough visual starting point</strong> that I could immediately hand off into implementation. And that changed the next part of my workflow completely.</p><p>Because once I had a UI structure I liked, I didn’t need to “design” anymore.</p><p>I could build.</p><h3>My Actual Workflow: Stitch → Codex → Cursor → Polish</h3><p>This is the part that made the whole thing click for me.</p><p>Instead of treating Stitch like a standalone design tool, I used it as the first stage in a larger AI-assisted development pipeline.</p><p>The workflow looked like this:</p><pre>Stitch → Generate UI idea<br>↓<br>Review layout and adjust prompt if needed<br>↓<br>Take the generated design structure/screenshots<br>↓<br>Paste into Codex or Cursor<br>↓<br>Convert into React components<br>↓<br>Refine styling, spacing, interactions, and responsiveness manually</pre><p>That flow is much more powerful than it sounds.</p><p>Because the hardest part of frontend work often isn’t typing the code. It’s reducing ambiguity.</p><p>Once the ambiguity is lower, AI tools become dramatically more useful.</p><p>If you ask Codex or Cursor to “build a dashboard,” you’ll get something. But it might not be the something you had in mind.</p><p>If you instead say:</p><blockquote><em>Build a React dashboard based on this layout. Use a clean card-based design with a top summary section, heart rate chart, sleep card, recent workouts list, and bottom nav.</em></blockquote><p>Now you’re giving the model structure, not just intent.</p><p>That makes the output much better.</p><h3>Example: Taking Stitch Into React</h3><p>Once I had the dashboard idea from Stitch, I tried converting it into a simple React/Tailwind implementation.</p><p>A prompt like this worked much better than a generic “build a fitness dashboard” request:</p><pre>Convert this UI into a React app using Tailwind CSS.<br>Requirements:<br>- Mobile-first responsive layout<br>- Reusable card components<br>- One chart placeholder section<br>- Bottom navigation with 4 tabs<br>- Keep the look modern and minimal</pre><p>The generated structure became much more coherent because the model had something visual to anchor to.</p><p>Here’s a simplified version of what the output might look like:</p><pre>export default function FitnessDashboard() {<br>  return (<br>    &lt;div className=&quot;min-h-screen bg-gray-50 p-4&quot;&gt;<br>      &lt;h1 className=&quot;text-2xl font-bold mb-4&quot;&gt;Today&lt;/h1&gt;&lt;div className=&quot;grid gap-4&quot;&gt;<br>        &lt;div className=&quot;rounded-2xl bg-white p-4 shadow-sm&quot;&gt;<br>          &lt;p className=&quot;text-sm text-gray-500&quot;&gt;Steps&lt;/p&gt;<br>          &lt;p className=&quot;text-3xl font-semibold&quot;&gt;8,421&lt;/p&gt;<br>        &lt;/div&gt;<br>        &lt;div className=&quot;rounded-2xl bg-white p-4 shadow-sm&quot;&gt;<br>          &lt;p className=&quot;text-sm text-gray-500&quot;&gt;Heart Rate&lt;/p&gt;<br>          &lt;div className=&quot;h-32 mt-2 rounded-xl bg-gray-100 flex items-center justify-center&quot;&gt;<br>            Chart Placeholder<br>          &lt;/div&gt;<br>        &lt;/div&gt;<br>        &lt;div className=&quot;rounded-2xl bg-white p-4 shadow-sm&quot;&gt;<br>          &lt;p className=&quot;text-sm text-gray-500&quot;&gt;Recent Workouts&lt;/p&gt;<br>          &lt;ul className=&quot;mt-2 space-y-2 text-sm&quot;&gt;<br>            &lt;li&gt;Morning Walk - 32 min&lt;/li&gt;<br>            &lt;li&gt;Upper Body - 45 min&lt;/li&gt;<br>            &lt;li&gt;Stretching - 12 min&lt;/li&gt;<br>          &lt;/ul&gt;<br>        &lt;/div&gt;<br>      &lt;/div&gt;<br>    &lt;/div&gt;<br>  )<br>}<br></pre><p>Would I ship that exact code as-is? No.</p><p>But that’s not the point.</p><p>The point is that instead of spending the first hour trying to decide layout, sections, and visual structure, I’m now spending that hour improving something that already exists.</p><p>That’s a much better use of time.</p><h3>Example: Taking the Same Idea Back Into SwiftUI</h3><p>Even though I’m intentionally taking a break from mobile for a bit, I also wanted to test whether this kind of workflow would still be useful for someone like me who naturally thinks in mobile UI patterns.</p><p>So I tried taking the same Stitch-generated dashboard and converting it into SwiftUI.</p><p>That looked something like this:</p><pre>import SwiftUI<br><br>struct FitnessDashboardView: View {<br>    var body: some View {<br>        NavigationStack {<br>            ScrollView {<br>                VStack(spacing: 16) {<br>                    SummaryCard(title: &quot;Steps&quot;, value: &quot;8,421&quot;)<br>                    HeartRateCard()<br>                    SleepCard(hours: &quot;7h 42m&quot;)<br>                    RecentWorkoutsCard()<br>                }<br>                .padding()<br>            }<br>            .navigationTitle(&quot;Today&quot;)<br>        }<br>    }<br>}<br>struct SummaryCard: View {<br>    let title: String<br>    let value: String<br>    var body: some View {<br>        VStack(alignment: .leading, spacing: 8) {<br>            Text(title)<br>                .font(.subheadline)<br>                .foregroundStyle(.secondary)<br>            Text(value)<br>                .font(.largeTitle.bold())<br>        }<br>        .frame(maxWidth: .infinity, alignment: .leading)<br>        .padding()<br>        .background(.white)<br>        .clipShape(RoundedRectangle(cornerRadius: 20))<br>        .shadow(radius: 3)<br>    }<br>}</pre><p>Again, not a finished product.</p><p>But very usable as a starting point. And that’s what I think a lot of developers still underestimate with AI tooling. You don’t always need perfection.</p><p>You often just need a strong enough first draft that lets your brain move into refinement mode instead of invention mode.</p><h3>Where Stitch Actually Shines</h3><p>After using it a bit, I don’t think Stitch is most valuable for designers trying to replace Figma.</p><p>I think it’s most valuable for developers, indie hackers, and builders who want to reduce the cost of getting from an idea to a believable interface.</p><p>That makes it especially useful for:</p><ul><li>MVPs</li><li>Side projects</li><li>Internal tools</li><li>Hackathon builds</li><li>App validation experiments</li><li>Landing pages</li><li>Admin dashboards</li><li>Early mobile UI exploration</li></ul><p>This is exactly the stage where speed matters most.</p><p>If you’re trying to validate an idea, you should not be spending three days manually polishing the first screen before you even know if the product is worth building.</p><p>Tools like Stitch are powerful because they help you get something real in front of your eyes quickly.</p><p>That’s often enough to either move forward with confidence or realize your original idea needs work.</p><p>Both outcomes are useful.</p><h3>Where It Still Falls Short</h3><p>Now for the important part: Stitch is not magic.</p><p>And if you go into it expecting it to produce polished, production-grade, highly branded interfaces out of the box, you’ll probably be disappointed.</p><p>There are still very obvious limitations.</p><p>For example:</p><ul><li>It won’t understand your exact product taste every time.</li><li>It may generate layouts that feel slightly generic.</li><li>It won’t replace design judgment.</li><li>It still needs human cleanup.</li><li>It doesn’t remove the need for frontend implementation skills.</li></ul><p>And honestly, that’s fine.</p><p>Because I don’t think tools like this need to be perfect to be valuable.</p><p>They just need to remove enough friction that your workflow improves.</p><p>And Stitch definitely did that for me.</p><h3>The Bigger Shift This Points To</h3><p>The most interesting part of this isn’t Stitch itself.</p><p>It’s what Stitch represents.</p><p>For a long time, design and implementation have been separated by a frustrating handoff. Even when one person is doing both jobs, there’s still a mental context switch between imagining a screen and actually building it.</p><p>What tools like Stitch are starting to do is compress that gap.</p><p>And when you combine that with tools like Codex, Cursor, Claude Code, or even strong prompt-based workflows, you start to realize we’re entering a very different kind of development loop.</p><p>The new loop looks more like this:</p><ul><li>Describe what you want</li><li>Get a visual starting point</li><li>Turn it into code</li><li>Refine it fast</li><li>Ship sooner</li></ul><p>That is a very different experience from the old “start from a blank file and manually create everything from scratch” workflow.</p><p>And honestly, I don’t think most developers fully appreciate yet how much this changes solo building.</p><p>If you’re a single developer trying to test ideas, move fast, and ship more, this is a massive unlock.</p><h3>My Honest Take After Trying It</h3><p>If you asked me whether Google Stitch is something I’d use every single day for every project, the honest answer is no.</p><p>But if you asked me whether it has earned a place in my workflow for frontend experiments, MVPs, UI exploration, and fast implementation cycles, the answer is definitely yes.</p><p>What I like most is that it helps me skip the most annoying part of UI work: the slow, fuzzy beginning where the app exists only vaguely in your head.</p><p>That stage used to require a lot more energy.</p><p>Now, with tools like this, I can get to “something real” much faster.</p><p>And once I have something real, I know what to do next.</p><p>That’s where all of this becomes useful.</p><p>Not because AI built the app for me.</p><p>But because it helped me start better.</p><h3>GitHub Repo</h3><p>I’ll probably clean up one of these Stitch-based experiments and publish the code soon.</p><p><strong>GitHub Repo:</strong></p><p><a href="https://github.com/sanjaynela/personalFitnessApp">GitHub - sanjaynela/personalFitnessApp: A sample fitness app that was designed with Google&#39;s Stitch</a></p><p><strong>Screenshots of Mobile App:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/762/1*YKzayO6t8043QdK7ngNI9Q.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/748/1*65nBhaBTJr5iIpMYs_L0Zw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/760/1*oZXgJe7D7b6bTMtzzZtxUg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/758/1*DwnI4Y_pyzCnOOpeW-jEkQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/774/1*YOV7b7BzQCtP6SsPyz0XhQ.png" /></figure><h3>Conclusion</h3><p>If you’re already using AI tools to write code, Google Stitch feels like a very natural next step. It doesn’t replace design thinking, and it definitely doesn’t replace frontend engineering, but it does make the path from idea to implementation much shorter.</p><p>And right now, that’s probably the most valuable thing a tool like this can do.</p><p>If you’ve been curious about AI-generated UI but assumed it was mostly hype, I’d say Stitch is worth trying, especially if you care more about building momentum than chasing perfection on day one.</p><p>That’s how I’m using it, and honestly, it fits surprisingly well into the kind of workflow I think more developers will adopt over the next year.</p><p>If you’ve tried Google Stitch, Codex, Cursor, or any similar workflow, I’d genuinely love to hear how you’re using them. Drop your thoughts in the comments, and if this article was useful, feel free to clap and follow. That really helps more people discover my work and lets me know what kinds of experiments to write about next.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=edd4c94ab097" width="1" height="1" alt=""><hr><p><a href="https://levelup.gitconnected.com/i-tried-google-stitch-for-ui-design-and-it-changed-how-i-build-apps-edd4c94ab097">I Tried Google Stitch for UI Design and It Changed How I Build Apps</a> was originally published in <a href="https://levelup.gitconnected.com/">Level Up Coding</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[I Let Codex Design and Test a Frontend App for Me. Here’s What Actually Happened]]></title>
            <link>https://levelup.gitconnected.com/i-let-codex-design-and-test-a-frontend-app-for-me-heres-what-actually-happened-a192b514f27e?source=rss-85a120b73062------2</link>
            <guid isPermaLink="false">https://medium.com/p/a192b514f27e</guid>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[openai]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[xcode]]></category>
            <category><![CDATA[software-development]]></category>
            <dc:creator><![CDATA[Sanjay Nelagadde]]></dc:creator>
            <pubDate>Mon, 23 Mar 2026 16:21:09 GMT</pubDate>
            <atom:updated>2026-03-23T16:21:09.412Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9hcnfBD7cR1HQ_Fwkn7vww.png" /><figcaption>Image by author</figcaption></figure><p>A little while ago, I wrote about finally trying Codex 5.4 because I kept hearing that it could control the computer, and honestly, I was too curious to ignore that. That earlier article was really about the moment of curiosity itself. I had been trying Codex more seriously, I was enjoying it a lot, and I wanted to see whether the hype around 5.4 was real or just another “this changes everything” moment that sounds more dramatic in demos than it feels in actual work. If you haven’t read that one yet, here it is: <a href="https://medium.com/write-a-catalyst/i-heard-codex-5-4-could-control-the-computer-so-i-had-to-try-it-20308a623145">I Heard Codex 5.4 Could Control the Computer, So I Had to Try It</a>. This article is the follow-up I actually wanted to write after that one. Not another general take on the model, but a more practical question: what happens if I specifically try the two things that stood out most to me in <a href="https://www.youtube.com/watch?v=YICiHiU2GBU">OpenAI’s own video</a>, <strong>namely persistent computer-use capabilities and image generation for frontend design?</strong></p><p>That combination immediately caught my attention because it maps surprisingly well to how frontend work actually feels in real life. Frontend development is rarely just “write a component and move on.” It is usually a loop of building something, opening it in the browser, noticing that the spacing feels weird, realizing the mobile version is worse, changing the layout, refreshing again, then arguing with yourself about whether the page looks polished or just expensive-looking. On top of that, a lot of the hardest parts are not even coding problems. Sometimes the issue is that you do not know what the page should feel like yet. You want a nicer hero section, but “nicer” is not a real technical requirement. You want the design to feel premium, but premium can mean ten different things depending on the product. That is why this particular Codex workflow felt worth testing. If one part of the system can help generate visual direction and another part can help inspect and interact with the actual rendered result, then suddenly the loop starts to look a lot more like real product work and a lot less like glorified autocomplete.</p><p>So instead of writing another abstract article, I decided the best way to explain this was through a concrete project that I would actually be comfortable putting on GitHub. That way the article could stay grounded in something tangible. Rather than saying, “Here are some cool features,” I could say, “Here is the exact kind of app I would build, here is how I would structure the repo, here is where computer use helps, here is where image generation helps, and here is what I would still verify myself.” That felt more honest and also more useful.</p><h3>The Project I’d Build for This Experiment</h3><p>The demo project I would use is a small frontend app called <strong>PromptBoard</strong>. The idea is simple: PromptBoard is a fictional product for developers who save, organize, and test prompts for coding agents. I like this kind of fake product for demos because it feels close enough to a real developer tool that the UI decisions make sense, but it is also flexible enough to show both marketing-style design work and interactive app behavior. In other words, it gives me exactly what I need to test these two concepts properly. On one side, I get a landing page where design, hierarchy, and visual mood matter. On the other side, I get a small dashboard flow where the browser interaction loop matters, because there are things to click, filter, search, and verify.</p><p>The landing page would include a hero section, a feature grid, a testimonial strip, pricing cards, and a final call-to-action section. The dashboard side would include a prompt library, tag filters, a search box, a create-prompt form, a detail view, and responsive behavior for smaller screens. That is enough to make the project feel like a real app without turning it into a giant side quest that nobody reading the article will ever rebuild. It is also the kind of repo that looks good on GitHub because it tells a very clear story: this is a focused demo built to test a modern agent workflow for frontend development.</p><p><strong>GitHub Repo :</strong> <a href="https://github.com/sanjaynela/promptBoard">https://github.com/sanjaynela/promptBoard</a></p><h3>What These Two Concepts Actually Mean</h3><p>Before getting into the code, I think it helps to explain these two ideas in a way that feels normal and not like marketing language. When I say <strong>computer use</strong> in this article, I am not trying to make it sound magical. I am talking about the ability for the model to work through more of the real UI loop instead of staying trapped at the source-code layer. In practical terms, that means the workflow is no longer just “read files and generate code.” It becomes closer to “run the app, open the page, inspect what is on screen, interact with it, notice what is off, and then go back and make a targeted fix.” For frontend work, that matters a lot because many of the problems we care about are not obvious from reading JSX alone. A component can look perfectly fine in code and still render awkwardly on mobile, misalign in a real browser, or feel visually weak once the full page is visible.</p><p>The <strong>image generation</strong> part is different, but just as useful. I do not see it as “AI making random pretty pictures.” I see it as a way to shorten that annoying period where you know the page needs a better visual direction but you do not yet know exactly what that direction should be. A lot of frontend work gets stuck there. The logic is fine. The structure is fine. The page works. But it still feels flat or generic. Image generation helps at that earlier stage where you are trying to figure out the mood, composition, contrast, illustration style, or overall design language. Sometimes the output becomes a direct asset you can use in the page. Other times it is more like a reference image that helps the coding agent understand what “premium dark developer tool” is supposed to look like in practice. Either way, it gives the workflow something visual to react to instead of relying only on vague adjectives.</p><p>That is really why these two ideas belong together. One helps decide what the frontend should look like. The other helps verify whether the frontend actually ended up looking and behaving the way you intended.</p><h3>The Stack I’d Use</h3><p>For this kind of demo, I would keep the stack extremely familiar on purpose. I do not want readers to get distracted by infrastructure or wonder whether the magic only works because of some exotic setup. I would use <strong>Next.js</strong>, <strong>TypeScript</strong>, <strong>Tailwind CSS</strong>, a few <strong>shadcn/ui</strong> components, <strong>Lucide</strong> icons, and <strong>Playwright</strong> for browser tests. That stack is boring in the best possible way. It is easy to understand, easy to run locally, and very believable as a GitHub project someone might actually clone.</p><p>The folder structure would look something like this:</p><pre>promptboard/<br>├── app/<br>│   ├── page.tsx<br>│   ├── dashboard/page.tsx<br>│   ├── globals.css<br>│   └── layout.tsx<br>├── components/<br>│   ├── landing/<br>│   │   ├── hero.tsx<br>│   │   ├── feature-grid.tsx<br>│   │   ├── testimonial-strip.tsx<br>│   │   └── pricing.tsx<br>│   ├── dashboard/<br>│   │   ├── sidebar.tsx<br>│   │   ├── prompt-card.tsx<br>│   │   ├── create-prompt-form.tsx<br>│   │   └── prompt-detail-modal.tsx<br>│   └── ui/<br>├── data/<br>│   └── prompts.ts<br>├── public/<br>│   ├── generated/<br>│   │   ├── hero-concept-1.png<br>│   │   ├── hero-concept-2.png<br>│   │   └── dashboard-mood.png<br>├── tests/<br>│   └── promptboard.spec.ts<br>├── AGENTS.md<br>├── README.md<br>└── package.json</pre><p>The public/generated folder is an important detail because it gives the project a very visible way to show where image generation fits into the workflow. I like the idea that the repo itself documents the process. A reader can see that some images were generated as visual direction, then see those assets referenced in the UI, and then see how the rest of the code evolved around them.</p><h3>Building the First Version of the Landing Page</h3><p>I would start with a clean but simple homepage, not because I expect the first version to be amazing, but because I want something real enough for the workflow to improve. That part matters. If the first pass is already overdesigned, the article becomes less believable. A better story is to start with a solid baseline and then show how the combination of image generation and browser-aware iteration helps push it further.</p><p>Here is a straightforward app/page.tsx:</p><pre>import { Hero } from &quot;@/components/landing/hero&quot;;<br>import { FeatureGrid } from &quot;@/components/landing/feature-grid&quot;;<br>import { TestimonialStrip } from &quot;@/components/landing/testimonial-strip&quot;;<br>import { Pricing } from &quot;@/components/landing/pricing&quot;;<br><br>export default function HomePage() {<br>  return (<br>    &lt;main className=&quot;min-h-screen bg-slate-950 text-white&quot;&gt;<br>      &lt;Hero /&gt;<br>      &lt;FeatureGrid /&gt;<br>      &lt;TestimonialStrip /&gt;<br>      &lt;Pricing /&gt;<br>      &lt;section className=&quot;border-t border-white/10 px-6 py-16&quot;&gt;<br>        &lt;div className=&quot;mx-auto max-w-5xl rounded-3xl border border-white/10 bg-white/5 p-8 text-center backdrop-blur&quot;&gt;<br>          &lt;h2 className=&quot;text-3xl font-semibold tracking-tight sm:text-4xl&quot;&gt;<br>            Build better prompts with a workflow you can actually trust.<br>          &lt;/h2&gt;<br>          &lt;p className=&quot;mx-auto mt-4 max-w-2xl text-base text-slate-300 sm:text-lg&quot;&gt;<br>            Organize agent prompts, test them faster, and keep your team&#39;s best workflows reusable.<br>          &lt;/p&gt;<br>          &lt;button className=&quot;mt-6 rounded-full bg-white px-6 py-3 text-sm font-semibold text-slate-950 transition hover:scale-[1.02]&quot;&gt;<br>            Start Free Trial<br>          &lt;/button&gt;<br>        &lt;/div&gt;<br>      &lt;/section&gt;<br>    &lt;/main&gt;<br>  );<br>}</pre><p>And here is a first-pass hero component:</p><pre>import Image from &quot;next/image&quot;;<br>import Link from &quot;next/link&quot;;<br><br>export function Hero() {<br>  return (<br>    &lt;section className=&quot;relative overflow-hidden px-6 pb-20 pt-24&quot;&gt;<br>      &lt;div className=&quot;absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(99,102,241,0.35),_transparent_40%),radial-gradient(circle_at_80%_20%,_rgba(16,185,129,0.22),_transparent_25%)]&quot; /&gt;<br>      &lt;div className=&quot;relative mx-auto grid max-w-6xl items-center gap-12 lg:grid-cols-2&quot;&gt;<br>        &lt;div&gt;<br>          &lt;div className=&quot;inline-flex rounded-full border border-white/10 bg-white/5 px-3 py-1 text-sm text-slate-300 backdrop-blur&quot;&gt;<br>            For developers who work with coding agents every day<br>          &lt;/div&gt;<br>          &lt;h1 className=&quot;mt-6 text-5xl font-semibold tracking-tight sm:text-6xl&quot;&gt;<br>            Your prompt library should feel as polished as your codebase.<br>          &lt;/h1&gt;<br>          &lt;p className=&quot;mt-6 max-w-xl text-lg leading-8 text-slate-300&quot;&gt;<br>            PromptBoard helps you save, organize, version, and test prompts for tools like Codex,<br>            ChatGPT, and Claude without losing the workflows that actually work.<br>          &lt;/p&gt;<br>          &lt;div className=&quot;mt-8 flex flex-wrap gap-4&quot;&gt;<br>            &lt;Link<br>              href=&quot;/dashboard&quot;<br>              className=&quot;rounded-full bg-white px-6 py-3 text-sm font-semibold text-slate-950&quot;<br>            &gt;<br>              Open Dashboard<br>            &lt;/Link&gt;<br>            &lt;button className=&quot;rounded-full border border-white/15 px-6 py-3 text-sm font-semibold text-white&quot;&gt;<br>              Watch Demo<br>            &lt;/button&gt;<br>          &lt;/div&gt;<br>        &lt;/div&gt;<br>        &lt;div className=&quot;relative&quot;&gt;<br>          &lt;div className=&quot;rounded-[2rem] border border-white/10 bg-white/5 p-4 shadow-2xl backdrop-blur-xl&quot;&gt;<br>            &lt;Image<br>              src=&quot;/generated/hero-concept-1.png&quot;<br>              alt=&quot;PromptBoard hero concept&quot;<br>              width={1200}<br>              height={900}<br>              className=&quot;rounded-[1.5rem] object-cover&quot;<br>            /&gt;<br>          &lt;/div&gt;<br>        &lt;/div&gt;<br>      &lt;/div&gt;<br>    &lt;/section&gt;<br>  );<br>}</pre><p>This is already a decent start, but more importantly, it gives me something that can actually be improved. That is the point. I do not want the article to pretend the first version is perfect. I want it to show the loop.</p><h3>Where Image Generation Enters the Workflow</h3><p>This is the part I find easiest to relate to because it solves a very human problem. Sometimes when I am building a frontend, I do not have a code problem yet. I have a taste problem. I know the page needs a better visual identity, but I do not want to spend an hour trying to invent one from scratch while pretending I am suddenly an art director. That is where I would use image generation first, not as decoration, but as visual guidance.</p><p>Instead of saying something vague like “make it premium,” I would ask for a few concrete visual concepts for a dark-mode SaaS product aimed at developers. I would want the image outputs to explore layout mood, lighting, card depth, dashboard atmosphere, and overall style. Something like this:</p><pre>Generate 3 visual directions for a SaaS landing page hero image for a product called PromptBoard.<br><br>The product helps developers organize and test prompts for coding agents.<br>Style requirements:<br>- Modern<br>- Premium<br>- Dark mode friendly<br>- Subtle glassmorphism<br>- Feels like a real B2B developer tool<br>- Not cartoonish<br>- Clean composition<br>- Slight neon accents<br>- Suitable for a React / Next.js landing page<br>Return concepts that could work as:<br>1. Hero illustration<br>2. Product mood board<br>3. Dashboard atmosphere reference</pre><p>I like this because it is practical. It does not assume the generated output is the final answer. It just gives the workflow something visual to react to. Maybe one image becomes a direct hero asset. Maybe another becomes a reference for how much glow, depth, and contrast the page should use. Either way, I now have a better design conversation than simply typing “make it look better.” For me, that is already a meaningful improvement.</p><h3>Building the Dashboard So Computer Use Has Something Real to Inspect</h3><p>Next, I would add a small dashboard page because that is where the computer-use side becomes more interesting. A static landing page is useful, but interactive UI gives the browser loop something to do. I want filters, search, cards, forms, and mobile layout behavior because those are the things that often look fine in code but feel off when you actually open the page.</p><p>Here is the seed data for the prompt library:</p><pre>export type PromptItem = {<br>  id: string;<br>  title: string;<br>  category: &quot;review&quot; | &quot;refactor&quot; | &quot;frontend&quot; | &quot;release&quot;;<br>  tags: string[];<br>  description: string;<br>};<br><br>export const prompts: PromptItem[] = [<br>  {<br>    id: &quot;1&quot;,<br>    title: &quot;Review React PR for visual regressions&quot;,<br>    category: &quot;review&quot;,<br>    tags: [&quot;react&quot;, &quot;ui&quot;, &quot;qa&quot;],<br>    description:<br>      &quot;Inspect component changes for layout regressions, broken states, and accessibility issues.&quot;<br>  },<br>  {<br>    id: &quot;2&quot;,<br>    title: &quot;Rewrite landing page hero for sharper CTA&quot;,<br>    category: &quot;frontend&quot;,<br>    tags: [&quot;copy&quot;, &quot;marketing&quot;, &quot;hero&quot;],<br>    description:<br>      &quot;Improve headline clarity, supporting text, and CTA hierarchy without sounding generic.&quot;<br>  },<br>  {<br>    id: &quot;3&quot;,<br>    title: &quot;Convert utility-heavy JSX into reusable components&quot;,<br>    category: &quot;refactor&quot;,<br>    tags: [&quot;tailwind&quot;, &quot;cleanup&quot;, &quot;components&quot;],<br>    description:<br>      &quot;Refactor repetitive UI blocks into maintainable components while preserving existing behavior.&quot;<br>  }<br>];</pre><p>And here is a clean first version of the dashboard page:</p><pre>&quot;use client&quot;;<br><br>import { useMemo, useState } from &quot;react&quot;;<br>import { prompts } from &quot;@/data/prompts&quot;;<br>import { PromptCard } from &quot;@/components/dashboard/prompt-card&quot;;<br>import { CreatePromptForm } from &quot;@/components/dashboard/create-prompt-form&quot;;<br>export default function DashboardPage() {<br>  const [selectedTag, setSelectedTag] = useState&lt;string&gt;(&quot;all&quot;);<br>  const [search, setSearch] = useState(&quot;&quot;);<br>  const tags = useMemo(() =&gt; {<br>    const allTags = prompts.flatMap((item) =&gt; item.tags);<br>    return [&quot;all&quot;, ...Array.from(new Set(allTags))];<br>  }, []);<br>  const filtered = useMemo(() =&gt; {<br>    return prompts.filter((item) =&gt; {<br>      const matchesTag = selectedTag === &quot;all&quot; || item.tags.includes(selectedTag);<br>      const matchesSearch =<br>        item.title.toLowerCase().includes(search.toLowerCase()) ||<br>        item.description.toLowerCase().includes(search.toLowerCase());<br>      return matchesTag &amp;&amp; matchesSearch;<br>    });<br>  }, [selectedTag, search]);<br>  return (<br>    &lt;main className=&quot;min-h-screen bg-slate-950 px-6 py-10 text-white&quot;&gt;<br>      &lt;div className=&quot;mx-auto max-w-6xl space-y-8&quot;&gt;<br>        &lt;div className=&quot;flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between&quot;&gt;<br>          &lt;div&gt;<br>            &lt;h1 className=&quot;text-3xl font-semibold tracking-tight&quot;&gt;Prompt Library&lt;/h1&gt;<br>            &lt;p className=&quot;mt-2 text-slate-400&quot;&gt;<br>              Save your best agent workflows so you stop rewriting the same ideas every week.<br>            &lt;/p&gt;<br>          &lt;/div&gt;<br>          &lt;input<br>            value={search}<br>            onChange={(e) =&gt; setSearch(e.target.value)}<br>            placeholder=&quot;Search prompts&quot;<br>            className=&quot;w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white outline-none placeholder:text-slate-500 sm:max-w-xs&quot;<br>          /&gt;<br>        &lt;/div&gt;<br>        &lt;div className=&quot;flex flex-wrap gap-3&quot;&gt;<br>          {tags.map((tag) =&gt; (<br>            &lt;button<br>              key={tag}<br>              onClick={() =&gt; setSelectedTag(tag)}<br>              className={`rounded-full px-4 py-2 text-sm font-medium transition ${<br>                selectedTag === tag<br>                  ? &quot;bg-white text-slate-950&quot;<br>                  : &quot;border border-white/10 bg-white/5 text-slate-300&quot;<br>              }`}<br>            &gt;<br>              {tag}<br>            &lt;/button&gt;<br>          ))}<br>        &lt;/div&gt;<br>        &lt;div className=&quot;grid gap-6 lg:grid-cols-[1.4fr_0.8fr]&quot;&gt;<br>          &lt;section className=&quot;grid gap-4 md:grid-cols-2&quot;&gt;<br>            {filtered.map((item) =&gt; (<br>              &lt;PromptCard key={item.id} item={item} /&gt;<br>            ))}<br>          &lt;/section&gt;<br>          &lt;aside&gt;<br>            &lt;CreatePromptForm /&gt;<br>          &lt;/aside&gt;<br>        &lt;/div&gt;<br>      &lt;/div&gt;<br>    &lt;/main&gt;<br>  );<br>}</pre><p>This page already has enough going on to make the computer-use side valuable. You can open it, click the filters, type into the search bar, resize the viewport, and quickly start noticing whether the experience feels polished or just acceptable.</p><h3>The Repo Instructions Matter Too</h3><p>One thing I would absolutely include in a demo like this is an AGENTS.md file. If the whole point of the repo is to test an agent workflow, then the repo should help guide that workflow. I do not want the model randomly redesigning things every time I ask for a fix. I want the repository itself to explain the visual and behavioral rules.</p><pre># AGENTS.md<br><br>## Project overview<br>This repository is a demo project used to test Codex workflows for:<br>- Persistent computer-use style browser iteration<br>- Image-guided frontend design improvements<br>The stack is Next.js + TypeScript + Tailwind.<br>## Working rules<br>- Preserve the existing visual style unless the task explicitly asks for a redesign.<br>- Prefer small, reviewable UI changes over broad rewrites.<br>- After changing frontend code, run the app and verify the affected flow.<br>- Check both desktop and mobile layouts when editing page structure.<br>- Do not add a new dependency unless it clearly improves the demo.<br>- Prefer accessible controls and visible focus states.<br>- Keep the UI premium, clean, and realistic. Avoid cartoonish styling.<br>## Done definition<br>A task is complete only when:<br>1. The UI change is implemented.<br>2. The page renders without obvious layout regressions.<br>3. Interactive behavior is verified.<br>4. The final result matches the requested visual direction.</pre><p>I like this because it makes the project feel intentional. It also reflects something I keep learning with AI coding tools in general: the better your environment communicates your preferences, the less time you spend cleaning up weird “helpful” decisions later.</p><h3>Where Computer Use Starts Feeling Useful</h3><p>This is where the workflow becomes interesting to me. Imagine the first pass of the dashboard looks okay in code, but once the app is open in the browser, a few things feel off. Maybe the search box is cramped on smaller screens. Maybe the selected filter chip is technically visible but visually weak. Maybe the spacing between the header and controls makes the whole page feel tighter than it should. These are the kinds of problems that frontend developers notice in seconds once the browser is open, but they are surprisingly annoying to describe from memory if the model cannot see the result.</p><p>That is why I would give Codex a task more like this:</p><pre>Run the PromptBoard app and inspect the /dashboard page in the browser.<br><br>Look for:<br>- Mobile layout issues<br>- Weak visual hierarchy<br>- Any controls that feel cramped or hard to use<br>- Areas where spacing, contrast, or alignment feel off<br>Then make targeted fixes.<br>Do not redesign the whole page.<br>Keep the dark premium aesthetic.<br>After changes, verify the page again in the browser.</pre><p>To me, that is a much more natural frontend instruction than “rewrite the dashboard component.” It reflects what I actually care about. I do not want random code motion. I want the rendered result to feel better. That is a subtle difference, but it is a really important one.</p><p>A targeted improvement might look like this:</p><pre>&lt;div className=&quot;flex flex-col gap-4 md:flex-row md:items-center md:justify-between&quot;&gt;<br>  &lt;div className=&quot;max-w-2xl&quot;&gt;<br>    &lt;h1 className=&quot;text-3xl font-semibold tracking-tight sm:text-4xl&quot;&gt;<br>      Prompt Library<br>    &lt;/h1&gt;<br>    &lt;p className=&quot;mt-2 text-sm leading-6 text-slate-400 sm:text-base&quot;&gt;<br>      Save your best agent workflows so you stop rewriting the same ideas every week.<br>    &lt;/p&gt;<br>  &lt;/div&gt;<br><br>&lt;input<br>    value={search}<br>    onChange={(e) =&gt; setSearch(e.target.value)}<br>    placeholder=&quot;Search prompts&quot;<br>    className=&quot;w-full rounded-2xl border border-white/12 bg-white/6 px-4 py-3.5 text-sm text-white shadow-[0_0_0_1px_rgba(255,255,255,0.03)] outline-none placeholder:text-slate-500 md:max-w-xs&quot;<br>  /&gt;<br>&lt;/div&gt;</pre><p>That is not a dramatic redesign, and that is exactly why I like it. It is believable. It is the kind of adjustment that actually improves a page without turning the entire repo into a moving target.</p><h3>Why I’d Still Add Playwright</h3><p>Even if I am testing a browser-aware workflow, I still want regular browser tests in the repo. I do not think those ideas compete with each other. In fact, I think they make each other better. The browser-aware workflow is great for iteration and quick visual judgment, but Playwright gives the repo a stable, repeatable way to verify important behavior.</p><p>Here is a simple test file I would include:</p><pre>import { test, expect } from &quot;@playwright/test&quot;;<br><br>test.describe(&quot;PromptBoard&quot;, () =&gt; {<br>  test(&quot;landing page renders hero and CTA&quot;, async ({ page }) =&gt; {<br>    await page.goto(&quot;http://localhost:3000&quot;);<br>    await expect(<br>      page.getByRole(&quot;heading&quot;, {<br>        name: /your prompt library should feel as polished as your codebase/i<br>      })<br>    ).toBeVisible();<br>    await expect(<br>      page.getByRole(&quot;button&quot;, { name: /start free trial/i })<br>    ).toBeVisible();<br>  });<br>  test(&quot;dashboard filter and search experience works&quot;, async ({ page }) =&gt; {<br>    await page.goto(&quot;http://localhost:3000/dashboard&quot;);<br>    await page.getByRole(&quot;button&quot;, { name: &quot;ui&quot; }).click();<br>    await page.getByPlaceholder(&quot;Search prompts&quot;).fill(&quot;React&quot;);<br>    await expect(<br>      page.getByText(/review react pr for visual regressions/i)<br>    ).toBeVisible();<br>  });<br>  test(&quot;mobile dashboard keeps search visible&quot;, async ({ browser }) =&gt; {<br>    const context = await browser.newContext({<br>      viewport: { width: 390, height: 844 }<br>    });<br>    const page = await context.newPage();<br>    await page.goto(&quot;http://localhost:3000/dashboard&quot;);<br>    await expect(page.getByPlaceholder(&quot;Search prompts&quot;)).toBeVisible();<br>  });<br>});</pre><p>This makes the repo easier to trust. A reader can see that the workflow is not just “the AI looked at the page and seemed happy.” There is still a real test harness, which I think matters a lot if the project is going on GitHub and not just living inside a demo video.</p><h3>Using Image Generation Again for the Second Pass</h3><p>After the first version of the app is working, I would go back to image generation one more time, but now with more focus. Instead of asking for broad concept art, I would ask for a hero visual or dashboard atmosphere image that specifically matches the product style I have already started building. Something like this:</p><pre>Generate a premium dark-mode SaaS illustration for a developer productivity product.<br><br>Requirements:<br>- Wide aspect ratio for website hero section<br>- Floating UI cards<br>- Prompt snippets, tags, and workflow nodes<br>- Deep navy and graphite background<br>- Emerald and violet accent lighting<br>- Realistic product-marketing look<br>- Minimal, not cartoonish<br>- Should feel believable for a GitHub portfolio project</pre><p>I like this because it is much more specific than just asking for a cool image. It gives the output a job to do. The generated asset can either be dropped directly into the hero or used as a design reference while the rest of the layout gets refined. That is a very practical way to use image generation in frontend work, and honestly, it feels much more relevant to me than the broader “AI art” conversation.</p><p>Here is what a refined hero component might look like after that second pass:</p><pre>import Image from &quot;next/image&quot;;<br>import Link from &quot;next/link&quot;;<br><br>export function Hero() {<br>  return (<br>    &lt;section className=&quot;relative overflow-hidden border-b border-white/10 px-6 pb-24 pt-24&quot;&gt;<br>      &lt;div className=&quot;absolute inset-0 bg-[radial-gradient(circle_at_20%_20%,rgba(16,185,129,0.18),transparent_30%),radial-gradient(circle_at_80%_10%,rgba(129,140,248,0.18),transparent_28%),linear-gradient(to_bottom,rgba(255,255,255,0.02),transparent)]&quot; /&gt;<br>      &lt;div className=&quot;relative mx-auto grid max-w-6xl gap-14 lg:grid-cols-[1.05fr_0.95fr] lg:items-center&quot;&gt;<br>        &lt;div&gt;<br>          &lt;div className=&quot;inline-flex items-center rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-medium uppercase tracking-[0.2em] text-slate-300 backdrop-blur-xl&quot;&gt;<br>            Organize. Test. Reuse.<br>          &lt;/div&gt;<br>          &lt;h1 className=&quot;mt-6 max-w-2xl text-5xl font-semibold tracking-tight text-white sm:text-6xl&quot;&gt;<br>            Build agent workflows that survive longer than one good day.<br>          &lt;/h1&gt;<br>          &lt;p className=&quot;mt-6 max-w-xl text-base leading-7 text-slate-300 sm:text-lg sm:leading-8&quot;&gt;<br>            PromptBoard gives developers a clean place to save proven prompts, review iterations,<br>            and turn scattered experiments into a repeatable system.<br>          &lt;/p&gt;<br>          &lt;div className=&quot;mt-8 flex flex-wrap gap-4&quot;&gt;<br>            &lt;Link<br>              href=&quot;/dashboard&quot;<br>              className=&quot;rounded-full bg-emerald-300 px-6 py-3 text-sm font-semibold text-slate-950 shadow-[0_12px_40px_rgba(110,231,183,0.22)] transition hover:translate-y-[-1px]&quot;<br>            &gt;<br>              Explore Dashboard<br>            &lt;/Link&gt;<br>            &lt;button className=&quot;rounded-full border border-white/10 bg-white/5 px-6 py-3 text-sm font-semibold text-white backdrop-blur-xl&quot;&gt;<br>              See Workflow<br>            &lt;/button&gt;<br>          &lt;/div&gt;<br>        &lt;/div&gt;<br>        &lt;div className=&quot;relative&quot;&gt;<br>          &lt;div className=&quot;absolute -inset-6 rounded-[2rem] bg-emerald-400/10 blur-3xl&quot; /&gt;<br>          &lt;div className=&quot;relative rounded-[2rem] border border-white/10 bg-white/5 p-4 shadow-2xl backdrop-blur-2xl&quot;&gt;<br>            &lt;Image<br>              src=&quot;/generated/hero-concept-2.png&quot;<br>              alt=&quot;PromptBoard UI concept&quot;<br>              width={1400}<br>              height={980}<br>              className=&quot;rounded-[1.5rem] object-cover&quot;<br>              priority<br>            /&gt;<br>          &lt;/div&gt;<br>        &lt;/div&gt;<br>      &lt;/div&gt;<br>    &lt;/section&gt;<br>  );<br>}</pre><p>To me, this is where the workflow starts to feel genuinely useful. There is a back-and-forth between visual direction and actual implementation. The generated image informs the design language, then the browser loop helps verify whether the real page actually lives up to that direction.</p><h3>What I Think Is Actually Interesting About This</h3><p>The thing I like most about this experiment is that it makes frontend AI workflows feel a little more honest. I do not mean “honest” in some philosophical sense. I mean honest in the sense that it acknowledges what frontend work really is. It is not just code generation. It is visual judgment, browser feedback, iteration, tiny design decisions, and the constant gap between what sounded good in a prompt and what actually looks right on the screen.</p><p>That is why this combination of computer use and image generation feels worth talking about. The image-generation part helps when the page needs direction. The computer-use part helps when the page needs verification. One helps before or during implementation, and the other helps after the code exists and has to prove itself in the browser. Put together, they create a loop that feels much closer to real product building.</p><p>At the same time, I would not pretend this removes the need for judgment. I still would not blindly trust visual taste just because an image looks polished. I still would not assume a browser interaction equals product understanding. I still want explicit prompts, repo guidance, and real tests. But I do think this workflow reduces friction in some very annoying parts of frontend development, and that alone makes it worth exploring.</p><h3>How I’d Package This on GitHub</h3><p>If I were publishing this project along with the article, I would keep the repo organized in a way that helps readers reproduce the same experiment. I would include a clean README, screenshots of the landing page and dashboard, the generated visual assets inside public/generated, a small docs/prompts.md file that shows the exact prompts used for both image generation and computer-use tasks, and maybe even a before-redesign branch so readers can compare the rough first pass with the improved version. That kind of repo tells a complete story. It does not just show code. It shows process.</p><p>A simple README.md could look like this:</p><pre># PromptBoard<br><br>PromptBoard is a demo project built to explore two GPT-5.4 / Codex-style frontend workflows:<br>1. Persistent computer-use style UI iteration<br>2. Image-guided frontend design<br>## What this repo demonstrates<br>- A modern landing page built in Next.js<br>- A small interactive dashboard flow<br>- Generated visual assets used in the UI design process<br>- Browser-based verification with Playwright<br>- A repo-level AGENTS.md file to guide agent behavior<br>## Stack<br>- Next.js<br>- TypeScript<br>- Tailwind CSS<br>- Playwright<br>## Local setup<br>~~~bash<br>npm install<br>npm run dev</pre><h3>Run tests</h3><pre>npx playwright test</pre><h3>Why this project exists</h3><p>This repo is meant to show how modern coding agents can go beyond code generation and participate in a fuller frontend loop: visual direction, implementation, browser inspection, and iteration.</p><h3>Conclusion</h3><p>What I like about this Codex workflow is not that it feels futuristic. It is that it feels closer to the real messiness of frontend work. A lot of the job is not writing syntax. It is deciding what a page should look like, getting it into the browser, noticing what feels off, and then iterating until it feels right. That is why this experiment interested me in the first place. Image generation helps with visual direction when I do not want to stare at a blank hero section pretending I already know the perfect aesthetic. Computer use helps with the part where the browser becomes the truth and the UI has to prove that it actually works and looks good.</p><p>So for me, this is the more interesting follow-up to my earlier Codex 5.4 article. That first piece was about curiosity. This one is about workflow. It is about taking two ideas from OpenAI’s own video and asking a much more practical question: if I built a real demo project around them, would the result feel useful or just impressive in theory? After thinking through this PromptBoard example, I think the answer is that it could be genuinely useful, especially for frontend work where visual feedback and browser behavior matter as much as the code itself.</p><p>The repo for the full code for this project is in the following link: <a href="https://github.com/sanjaynela/promptBoard">https://github.com/sanjaynela/promptBoard</a></p><p>If this article helped you think differently about AI-assisted frontend workflows, clap for it so more developers can find it, follow me for more articles like this, and leave a comment if you want me to turn this into a full GitHub walkthrough with screenshots, prompts, and the finished repo structure.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a192b514f27e" width="1" height="1" alt=""><hr><p><a href="https://levelup.gitconnected.com/i-let-codex-design-and-test-a-frontend-app-for-me-heres-what-actually-happened-a192b514f27e">I Let Codex Design and Test a Frontend App for Me. Here’s What Actually Happened</a> was originally published in <a href="https://levelup.gitconnected.com/">Level Up Coding</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[I Heard Codex 5.4 Could Control the Computer, So I Had to Try It]]></title>
            <link>https://medium.com/write-a-catalyst/i-heard-codex-5-4-could-control-the-computer-so-i-had-to-try-it-20308a623145?source=rss-85a120b73062------2</link>
            <guid isPermaLink="false">https://medium.com/p/20308a623145</guid>
            <category><![CDATA[claude-code]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[chatgpt]]></category>
            <category><![CDATA[machine-learning]]></category>
            <dc:creator><![CDATA[Sanjay Nelagadde]]></dc:creator>
            <pubDate>Sun, 22 Mar 2026 03:38:03 GMT</pubDate>
            <atom:updated>2026-03-22T03:38:03.114Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-_eJNER7m0Qntg_FtjZQ8g.png" /><figcaption>Image by author</figcaption></figure><p>A lot of AI coding conversations start to sound the same after a while. Someone says a model is better at coding, someone else says it is faster, and then a benchmark or screenshot gets passed around like that settles everything. But recently, I kept hearing something more specific about Codex 5.4. I heard it could do more than just answer questions or generate code. I heard it could actually help control the computer, move through workflows, and act a little more like an operator instead of just a chatbot with strong opinions.</p><p>That immediately made me curious. Not in a dramatic “AI is taking over” kind of way, but in a very normal developer way: wait, can this actually help me do real work with less back-and-forth?</p><p>I have recently been trying Codex, and honestly, I have been loving it so far. Before that, I had been spending a lot of time with Claude Code, and I still love Claude Code too. It genuinely changed how I think about AI-assisted development. It made me realize these tools become much more useful once you stop treating them like fancy chat windows and start using them as part of an actual workflow.</p><p>I wrote about that in two earlier articles: <a href="https://generativeai.pub/after-a-week-of-claude-code-10-things-i-wish-i-knew-on-day-one-81fd2a542c67"><em>After a Week of Claude Code: 10 Things I Wish I Knew on Day One</em></a> and <a href="https://medium.com/write-a-catalyst/claude-code-skills-the-one-feature-that-finally-stopped-me-from-repeating-myself-7189df272b1f"><em>Claude Code Skills: The One Feature That Finally Stopped Me From Repeating Myself</em></a>. Those articles came from real use, real friction, and real trial and error. This article comes from the same place.</p><p>I was already enjoying Claude Code. Then I started trying Codex more seriously. Then I heard people say Codex 5.4 could control the computer. At that point, I was obviously going to poke at it. Because once you hear something like that, it is very hard not to get curious.</p><p>So this article is really about that curiosity. What does it actually mean when people say Codex 5.4 can control the computer? Why does that matter? And why does that feel different from the usual “AI can help you code” story we have all heard a hundred times already?</p><h3>What People Mean When They Say Codex 5.4 Can “Control the Computer”</h3><p>Let’s make this less dramatic first. When people say Codex 5.4 can control the computer, they do not mean it has suddenly become a tiny digital employee living inside your laptop and making life choices on your behalf. What they usually mean is something much more practical: the model can move beyond just giving you instructions and help operate a workflow.</p><p>That might mean writing commands, running scripts, inspecting what is happening, interacting with tools, or helping bridge the annoying gap between “here is what you should do next” and “okay, now please actually do part of it.” That gap is where a lot of developer energy gets wasted.</p><p>For a long time, most AI tools felt like smart advisors. You asked a question, got an answer, and then went off to do the real work yourself. Open the terminal. Run the command. Open the browser. Check the page. Copy the error. Paste it back. Ask again. Repeat. That workflow is still useful, but it is also exhausting.</p><p>The exciting part here is that Codex starts to feel less like an advisor and more like an operator. Not perfect. Not magic. But more active. And that shift is a bigger deal than it sounds.</p><h3>Why This Caught My Attention</h3><p>The funny thing is, the more I liked Claude Code, the more curious I became about Codex. Sometimes people act like once you like one tool, you are supposed to pick a side and become weirdly loyal, like you are joining a sports team. That has never made much sense to me.</p><p>When I find a tool that genuinely improves my workflow, my first reaction is not, “Great, now I can ignore everything else forever.” It is usually, “Interesting. Now I want to understand what the other tools are good at too.” That is exactly what happened here.</p><p>Claude Code made me appreciate structured AI workflows more deeply. It made me care about memory, context, reusable prompts, and long-running coding sessions that feel less like one-off chats and more like collaboration. So when I started trying Codex and heard people describe things like computer control and auto script workflows, I wanted to see what that actually meant in practice.</p><p>Not as a headline. Not as a product demo. In actual day-to-day work. Because that is where the truth always shows up. A feature can sound incredible on social media and still be useless when you are trying to debug a broken screen late at night while your dev server is half-working and your patience is gone. That is the real test.</p><h3>The Shift From Chatting to Operating</h3><p>This is the part that feels genuinely new to me. Older AI workflows usually looked like this: you ask for help, the model tells you what to do, you do it manually, you come back with the result, the model gives the next suggestion, and you repeat the loop until either the problem is solved or both of you lose the will to continue.</p><p>That loop works. We have all used it. But it also means you are the bridge between the model and the task the entire time. You are the one copying commands, opening tabs, checking whether the page loaded, rerunning the test, and pasting logs. You are basically acting as human middleware.</p><p>And honestly, a surprising amount of software work is just that: human middleware.</p><p>The more interesting promise with something like Codex 5.4 is that you give the model a goal, it inspects context, it acts, it verifies, and it continues. That is a very different feeling. It means the value is not just in generating a good answer. The value is in reducing the number of times you have to manually hand off between intention and execution.</p><p>That is what makes “computer control” interesting to me. It is not that the model suddenly became infinitely smarter. It is that the workflow starts to feel more operational.</p><h3>What “Auto Script Control” Could Look Like in Real Life</h3><p>This is where it gets practical. Because if this idea only sounds impressive in theory, then it is not very useful.</p><p>One obvious place is localhost app testing. You are building a feature locally, you start the dev server, and you want to know if the page loads correctly, if the login flow works, if the button actually does anything, or if the layout broke in some weird new way. Normally, that means bouncing between your editor, terminal, browser, console, and whatever scraps of emotional resilience you still have left. A more agentic workflow could start the app, open the page, click through the main flow, notice visible issues, report what failed, and possibly try the obvious next step. That is where this starts to feel less like “write me a function” and more like “help me move work forward.”</p><p>Another good example is repetitive admin workflows. Every team has some version of this: a dashboard no one loves, an internal tool that works just well enough to remain alive, and a series of clicks everyone knows by memory because they have done it 600 times before. Open dashboard, search record, check status, open details, export something, go back, repeat. This kind of work is not intellectually deep. It is just repetitive. And repetitive work is exactly where controlled automation becomes interesting, not because it is glamorous, but because it is the kind of work people quietly lose time to every single week.</p><p>QA-style smoke testing is another area where the idea gets compelling fast. You make a change and want to know whether the core experience still works. Not whether your test suite passes in theory, but whether the app still behaves normally to a human looking at it. Can the page open? Can the user navigate? Is the modal blocking the screen? Did the layout collapse on a smaller viewport? Did a button disappear into the void? Traditional scripts are still important here, of course, but visual workflows catch a different class of problems. And sometimes those are the problems users notice first.</p><p>I also think setup and troubleshooting is a big one. Installing dependencies, running a project for the first time, figuring out why the environment is broken, seeing which command failed, and realizing the problem is not actually the command you thought it was. That kind of troubleshooting is rarely one clean step. It is more like a chain of small observations. That is where an assistant that can inspect, act, and continue starts feeling more helpful than one that just keeps throwing suggestions over the wall.</p><h3>Scripts vs Computer Use: Why the Difference Matters</h3><p>This distinction matters a lot because “script automation” and “computer use” sound similar, but they are not exactly the same thing.</p><p>Scripts are great when the workflow is structured and predictable. If you know the exact command, exact path, exact expected outcome, and exact trigger, scripts are wonderful. They are fast, clean, and reliable. But not every problem is that tidy.</p><p>Sometimes the issue depends on what is visually happening on screen. A page technically loaded, but the modal is blocking everything. A button exists, but it is off-screen. The form rendered, but the layout is broken. The app did not crash, but it looks obviously wrong. A script may happily report success in situations where a human would look at the page and say, “Yeah, this is broken.”</p><p>That is where computer-use style workflows become valuable. They are useful when the state of the task depends partly on what can be observed, not just what can be executed. To me, the sweet spot is not choosing one over the other. It is combining both. Let scripts handle the structured parts. Let computer-use workflows handle the messy, visual, context-heavy parts. That feels much closer to how real work actually happens.</p><h3>How I’d Want Codex 5.4 to Work in Practice</h3><p>This part matters a lot, because power without structure gets chaotic very quickly. If I am using something like Codex 5.4 in a more agentic mode, I want to give it a clear goal and define what success looks like. I want it to inspect the environment before acting, act in small understandable steps, verify instead of guess, and leave me a clean summary of what it actually did.</p><p>That last part is huge. I do not want an AI that just confidently clicks around, runs a few things, and then announces, “Everything looks good,” like an intern who is trying not to get caught. I want something that checks. Something that tells me what it observed. Something that says: here is what I ran, here is what happened, here is what failed, here is what I changed, and here is what still needs your attention.</p><p>That is the kind of workflow that builds trust. Not false confidence. Not vibes. Trust.</p><h3>What Still Makes Me Cautious</h3><p>As exciting as this is, I do not think it helps anyone to pretend the technology is flawless. There are still real reasons to be cautious. UI flows are fragile. Layouts change. Selectors break. Pages behave differently across environments. Permissions matter. Sensitive workflows need guardrails. And some tasks are simply too high-risk to hand over casually.</p><p>That is why I think it is important to stay grounded. I would absolutely use this kind of workflow for repetitive testing, setup help, UI inspection, and boring operational tasks. I would not blindly let it loose on something destructive just because the demo looked smooth.</p><p>That is not fear. That is just good judgment. The real opportunity here is not “remove humans completely.” It is “remove the boring glue work that humans should not have to keep doing manually.” That is a much more useful goal.</p><h3>Why This Feels Bigger Than Just Another Model Upgrade</h3><p>Every time a new model comes out, the internet immediately fills up with the usual conversations. Is it smarter? Is it faster? Did benchmark X go up? Did benchmark Y go down? Who won this week’s AI leaderboard argument?</p><p>All of that has some value, I guess. But what changes my day is not a benchmark chart. What changes my day is workflow compression. Less tab switching, less copy-pasting, less re-explaining, and less manual glue work between the editor, terminal, browser, logs, and testing flow.</p><p>That is what I care about. If a tool helps remove friction from actual work, that matters more to me than another screenshot proving it scored slightly higher on some abstract test. That is why this topic stuck with me. The interesting question is not just whether Codex 5.4 is “better.” The interesting question is whether it changes the shape of the work.</p><p>And I think computer control, even in its imperfect current form, points toward exactly that.</p><h3>Who Should Care About This</h3><p>I think this is especially interesting for developers who spend a lot of time debugging UI-heavy workflows, QA engineers who care about real visual behavior and not just pass-or-fail scripts, indie hackers who do not have time to manually repeat the same setup and testing steps all day, founders wearing six hats at once, and anyone dealing with repetitive browser workflows that are too annoying to keep doing by hand but too messy for traditional automation alone.</p><p>If you have never felt the pain of repetitive software workflows, this might sound like a neat feature and not much more. But if you have spent enough hours bouncing between your terminal, browser, logs, and broken local environment, you immediately understand why this feels exciting. It is not because it is flashy. It is because it is useful.</p><h3>Conclusion</h3><p>At the end of the day, this article came from a very simple place. I heard Codex 5.4 could control the computer, and I got curious.</p><p>I have been enjoying Codex a lot recently, and that curiosity made me want to understand what this feature really means once you strip away the buzzwords and look at it through the lens of real developer work. Claude Code already made me appreciate how powerful AI coding workflows can be when the tool fits naturally into how you actually work. Codex made me curious in a slightly different way. It made me ask whether AI could move one step closer from “helpful assistant” to “practical operator.”</p><p>That is the part I find exciting. Not because I think everything should be automated. Not because I think one tool will replace every other tool. But because reducing the annoying handoff between intention and execution is a genuinely meaningful improvement. And honestly, that is where I think a lot of the real progress is happening.</p><p>If you have been experimenting with Codex, Claude Code, or any AI workflow that goes beyond just chatting, I would love to hear how you are using it.</p><p>And if this article helped, gave you ideas, or matched something you have been curious about too, feel free to <strong>clap, follow, and leave a comment</strong>. I always enjoy hearing how other developers are actually using these tools in the real world.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=20308a623145" width="1" height="1" alt=""><hr><p><a href="https://medium.com/write-a-catalyst/i-heard-codex-5-4-could-control-the-computer-so-i-had-to-try-it-20308a623145">I Heard Codex 5.4 Could Control the Computer, So I Had to Try It</a> was originally published in <a href="https://medium.com/write-a-catalyst">Write A Catalyst</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>