{"id":62,"date":"2026-09-11T11:00:00","date_gmt":"2026-09-11T09:00:00","guid":{"rendered":"https:\/\/grindloop.io\/blog\/?p=62"},"modified":"2026-09-27T00:08:28","modified_gmt":"2026-09-26T22:08:28","slug":"compose-null-check-crash","status":"publish","type":"post","link":"https:\/\/grindloop.ai\/blog\/compose-null-check-crash\/","title":{"rendered":"The Null Check That Didn&#8217;t Save You"},"content":{"rendered":"<blockquote><p>A payment screen checks a nullable Compose state for null, then force-unwraps it on the next line. Under normal use it works. Under rapid interaction, a crash report shows a NullPointerException on the <code>!!<\/code>. How can a value that was just checked be null?<\/p><\/blockquote>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">var bankMismatchData by remember { mutableStateOf&lt;MismatchData?&gt;(null) }\n\nif (showBankMismatchSheet &amp;&amp; bankMismatchData != null) {\n    val mismatchData = bankMismatchData!!\n    BankMismatchSheet(mismatchData)\n}<\/pre>\n\n\n<p>This Compose null check crash comes from reading the same mutable state twice. <code>bankMismatchData<\/code> is a local delegated property. Every read goes through the delegate&#8217;s getter. The getter reads the snapshot state. The null check is one read. The <code>!!<\/code> is a second read. Kotlin knows the two reads might differ. That&#8217;s why it refuses to smart-cast here, so you end up writing <code>!!<\/code>. Within one composition pass, both reads usually see the same value. But when a read happens inside a lambda that runs later, it can see a newer value. Sheet content and callbacks are common examples of such lambdas. If another part of the screen has set the state to null by then, the <code>!!<\/code> throws. The fix is to read the state once into a local <code>val<\/code>. Then check and use that local. The compiler can smart-cast it. Nothing can change it between the check and the use.<\/p>\n\n<h2>A Compose null check crash from a real app<\/h2>\n\n<p>This pattern appears in a <a href=\"https:\/\/github.com\/openMF\/mifos-pay\/issues\/2023\" target=\"_blank\" rel=\"noopener\">May 2026 bug report on Mifos Pay<\/a>, an open-source wallet app. The report covers <code>FastMpayScreen<\/code>. That screen handles QR-code payments across banks. Two nullable mutable states, <code>bankMismatchData<\/code> and <code>pendingAmountConfirmation<\/code>, were each checked for null and then force-unwrapped. The reporter describes a NullPointerException after scanning a cross-bank QR code and interacting rapidly while the sheet renders.<\/p>\n\n<p>A contributor posted a fix from a fork. It reads each state into a local <code>val<\/code> before the null check. It also removes every <code>!!<\/code> from the file. When this was written, the upstream issue was still open.<\/p>\n\n<h2>Why Kotlin won&#8217;t smart-cast this state<\/h2>\n\n<p>Kotlin&#8217;s docs on <a href=\"https:\/\/kotlinlang.org\/docs\/typecasts.html\" target=\"_blank\" rel=\"noopener\">smart cast prerequisites<\/a> spell out the rule. Smart casts need a guarantee. The compiler must know the variable won&#8217;t change between the check and its use. Local <code>val<\/code>s always qualify, except local delegated properties. <code>var<\/code> properties never qualify.<\/p>\n\n<p><code>var x by remember { mutableStateOf(...) }<\/code> is a local delegated property. So the compiler won&#8217;t smart-cast it after a null check. That&#8217;s why the <code>!!<\/code> gets written in the first place. The compiler is telling you the two reads aren&#8217;t guaranteed to match. The <code>!!<\/code> gets past that compile error without fixing the cause.<\/p>\n\n<h2>The fix: read once, then branch<\/h2>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">\/\/ Unsafe: two reads of a delegated state property\nif (bankMismatchData != null) {\n    val mismatchData = bankMismatchData!!\n    BankMismatchSheet(mismatchData)\n}\n\n\/\/ Safe: one read, captured in a local val the compiler can smart-cast\nval mismatchData = bankMismatchData\nif (mismatchData != null) {\n    BankMismatchSheet(mismatchData)\n}<\/pre>\n\n\n<p>The second version reads the state once. The local <code>val<\/code> can&#8217;t change, so the compiler smart-casts it to non-null inside the <code>if<\/code>. Any lambda inside <code>BankMismatchSheet<\/code> now captures the checked value, not the live state. This isn&#8217;t a Compose trick. It&#8217;s the same discipline Kotlin asks for with any mutable property.<\/p>\n\n<h2>What to look for in code review<\/h2>\n\n<p>Watch for <code>remember { mutableStateOf(...) }<\/code> with a nullable type, followed by a null check and a <code>!!<\/code>. The buggy version compiles cleanly and looks guarded. It may only fail under timing you rarely hit in manual testing. The same applies to <code>var<\/code> properties in ViewModels and other classes.<\/p>\n\n<h2>How to answer this in an interview<\/h2>\n\n<ol>\n<li>Say that the check and the <code>!!<\/code> are two separate reads of mutable state.<\/li>\n<li>Explain why the compiler refused to smart-cast. The state is a local delegated property, so the compiler can&#8217;t guarantee both reads match.<\/li>\n<li>Name when the reads can differ. That happens when the second read runs later, inside a lambda such as sheet content or a callback.<\/li>\n<li>Give the fix as a rule. Read mutable state into a local <code>val<\/code> once, then branch on the local.<\/li>\n<\/ol>\n\n<p><strong>Common wrong answers:<\/strong><\/p>\n<ul>\n<li>&#8220;The null check makes it safe.&#8221; It checks one read. The <code>!!<\/code> is a different read.<\/li>\n<li>&#8220;Wrap it in <code>try<\/code>\/<code>catch<\/code>.&#8221; That hides the crash and still renders with bad state.<\/li>\n<li>&#8220;Recomposition is random.&#8221; The rule is specific. Reads inside lambdas can run later and see newer values.<\/li>\n<\/ul>\n\n<p>Another Kotlin feature quietly changes what gets compared. See <a href=\"https:\/\/grindloop.ai\/blog\/kotlin-data-class-equals-ignores-parent-class\/\">why a data class&#8217;s equals() ignores the parent class<\/a>.<\/p>\n\n<hr\/>\n\n<p><em>GrindLoop&#8217;s Bug-Squash track turns failure patterns like this one into live debugging drills, each with a reviewed fix.<\/em><\/p>\n\n<p><strong>Failed the interview? Not the next one.<\/strong><\/p>","protected":false},"excerpt":{"rendered":"<p>A Compose null check crash happens when a null check and a !! read mutable state twice. Why Kotlin refuses to smart-cast delegated state, the one-read fix and the interview answer.<\/p>\n","protected":false},"author":2,"featured_media":687,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"The Compose Null Check Crash Hiding in Plain Sight","rank_math_description":"A Compose null check crash happens when a null check and a !! read mutable state twice. Why Kotlin won't smart-cast delegated state, the one-read fix and the answer.","rank_math_focus_keyword":"Compose null check crash","footnotes":""},"categories":[9,2],"tags":[15,34,16,42,12],"class_list":["post-62","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-bug-squash","category-kotlin","tag-android","tag-code-review","tag-interview-prep","tag-jetpack-compose","tag-kotlin"],"_links":{"self":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/62","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/comments?post=62"}],"version-history":[{"count":6,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/62\/revisions"}],"predecessor-version":[{"id":686,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/62\/revisions\/686"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media\/687"}],"wp:attachment":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media?parent=62"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/categories?post=62"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/tags?post=62"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}