Skip to main content

Migrating from 1.x to 2.0

Elmish Land 2.0 moves the framework onto Fable 5 and Feliz 3. The runtime gets a few breaking changes — most are mechanical renames, but four areas (React.memo, React.lazy', the React context API, and any hand-written Interop.reactApi.createElement bindings) need a small restructuring you have to do by hand.

The dotnet elmish-land upgrade command does the mechanical work for you and prints a checklist of the manual edits, with a deep link into the Feliz upgrade docs for each one.

Upgrade to .NET 10 before upgrading Elmish Land

Elmish Land 2.0 requires the .NET 10 SDK. Move your existing project onto .NET 10 first, and only then run dotnet elmish-land upgrade. If you skip ahead and run dotnet tool update elmish-land --prerelease while .NET 10 isn't the active SDK (typically because your project's global.json pins an older version), the update fails with:

Unhandled exception: Settings file 'DotnetToolSettings.xml' was not found in the package.

Before running the commands below:

  1. Install the .NET 10 SDK from dot.net/download.

  2. Update your project's global.json so the sdk.version is a 10.x value you have installed — for example:

    {
    "sdk": {
    "version": "10.0.100",
    "rollForward": "latestFeature"
    }
    }

    (If your project has no global.json, you can skip this step — dotnet will pick up the newest installed SDK on its own.) dotnet elmish-land upgrade does not touch global.json; flip the SDK version yourself before running the upgrade.

  3. Bump the <TargetFramework> in every .fsproj file in your project from net9.0 (or older) to net10.0:

    <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    </PropertyGroup>

    dotnet elmish-land upgrade does not edit your project files' target framework — it only regenerates the .elmish-land/ projects. You need to flip this in your own .fsproj files by hand.

  4. Verify your project still compiles on .NET 10 by running dotnet build from the project root. Fix any SDK-related errors before continuing, so you have a clean baseline before introducing the elmish-land upgrade on top.

Note: once the active SDK is .NET 10, the elmish-land 1.1 tool can no longer run dotnet elmish-land build or dotnet elmish-land server — those commands were built against the older Fable and SDK combination. Plan to run the steps above and then immediately continue with dotnet tool update elmish-land --prerelease and dotnet elmish-land upgrade; don't stop in the middle expecting the 1.1 commands to keep working.

TL;DR

# 1. Update the elmish-land tool to 2.x
dotnet tool update elmish-land --prerelease

# 2. From inside your project directory, run the upgrade command
dotnet elmish-land upgrade

Then read the manual-migration list the upgrade command printed, and apply each fix using the linked Feliz docs as a reference.

What upgrade changes for you

Dependency versions

upgrade re-pins every managed package to its 2.0 major. Versions are resolved against the live NuGet and npm registries at the moment you run it, so you always land on the latest patch within each major.

Package1.12.0
fable (dotnet tool)45
FSharp.Core1010
Fable.Core(implicit)5
Fable.Promise33
Fable.Elmish55
Fable.Elmish.HMR89
Fable.Elmish.React55
Feliz23
Feliz.Router4removed
react1919
react-dom1919
vite78

The files upgrade rewrites:

  • Directory.Packages.props — re-pins every managed <PackageVersion> and removes the Feliz.Router entry (2.0 vendors a small Router at .elmish-land/Base/Router.fs instead).
  • package.json — bumps react, react-dom, and vite.
  • .config/dotnet-tools.json — bumps the fable tool to 5.x.
  • User .fsproj files — strips any leftover <PackageReference Include="Feliz.Router" /> entries. The default v1 scaffold kept this reference inside .elmish-land/Base/...fsproj, which is regenerated anyway; this cleanup catches projects that copied the reference up into a hand-edited project file.

Source-level renames

For every .fs file, these whole-identifier renames are applied:

Feliz 2Feliz 3
React.fragmentReact.Fragment
React.keyedFragmentReact.KeyedFragment
React.importedReact.Imported
React.dynamicImportedReact.DynamicImported
React.strictModeReact.StrictMode
React.suspenseReact.Suspense
React.createDisposableFsReact.createDisposable
React.useDisposableFsReact.useDisposable
React.useCancellationTokenFsReact.useCancellationToken

The matcher uses identifier boundaries on both sides, so it won't touch:

  • a longer name that starts with the old one (e.g. React.fragmentExtra)
  • code that's already been upgraded (re-running upgrade is a no-op)

What you have to migrate by hand

These three call sites can't be safely auto-rewritten because Feliz 3 splits one expression into a definition and a renderer. The upgrade command finds each occurrence and prints something like:

Manual migration required:
• src/Pages/Home.fs:42 — React.memo now requires explicit React.memoRenderer call sites at usage points
see https://fable-hub.github.io/Feliz/api-docs/Upgrade#reactmemo

Below is a quick reference for each pattern so you don't have to context-switch to the Feliz docs unless you want the full explanation.

React.memoReact.memoRenderer

The wrapper no longer renders itself. Define the memoized function once, then call React.memoRenderer at each use site.

Before (Feliz 2):

let MemoFunction = React.memo<{| text: string |}> (fun props ->
Html.div [ prop.text props.text ])

[<ReactComponent>]
let Main () =
Html.div [ MemoFunction {| text = "hi" |} ]

After (Feliz 3):

let MemoFunction = React.memo<{| text: string |}> (fun props ->
Html.div [ prop.text props.text ])

[<ReactComponent>]
let Main () =
Html.div [ React.memoRenderer (MemoFunction, {| text = "hi" |}) ]

Full details: Feliz upgrade — React.memo.

React.lazy'React.lazyRender

Same shape: the lazy definition stays, the call site uses React.lazyRender. In typical use it lives inside a React.Suspense.

Before (Feliz 2):

let LazyHello = React.lazy' (fun () -> JsInterop.importDynamic "./Hello")

[<ReactComponent>]
let SuspenseDemo () =
React.Suspense (
[ LazyHello () ],
Html.div [ prop.text "Loading..." ]
)

After (Feliz 3):

let LazyHello : LazyComponent<unit> =
React.lazy' (fun () -> JsInterop.importDynamic "./Hello")

[<ReactComponent>]
let SuspenseDemo () =
React.Suspense (
[ React.lazyRender (LazyHello, ()) ],
Html.div [ prop.text "Loading..." ]
)

Full details: Feliz upgrade — React.lazy'.

React.context redesign

React.contextProvider and React.contextConsumer are gone. The provider/consumer now live as members on the context value itself, much closer to how the React JS API reads.

Before (Feliz 2):

let CounterContext = React.createContext (None: (int * (int -> unit)) option)

[<ReactComponent>]
let UseContext () =
let count, setCount = React.useState 0
React.contextProvider (CounterContext, Some (count, setCount), CounterDisplay ())

After (Feliz 3):

let CounterContext = React.createContext (None: (int * (int -> unit)) option)

[<ReactComponent>]
let CounterDisplay () =
let ctx = React.useContext CounterContext
match ctx with
| Some (count, _) -> Html.p [ prop.text $"Current count: {count}" ]
| None -> Html.p [ prop.text "No context available" ]

[<ReactComponent(true)>]
let UseContext () =
let count, setCount = React.useState 0
CounterContext.Provider ((Some (count, setCount)), CounterDisplay ())

Full details: Feliz upgrade — React.context.

Hand-written React bindings

If your project wraps a JavaScript React component by hand (a common pattern when binding to libraries like Radix or Floating UI), the call into Feliz has moved. See the Feliz writing-bindings guide for the rewrite (in short: Interop.reactApi.createElement becomes ReactLegacy.createElement and the component argument needs an unbox<ReactElement> cast).

After the upgrade

npm install # installs React 19 / vite 8
dotnet elmish-land restore # regenerates the .elmish-land/ framework files
dotnet elmish-land build # sanity-check that everything compiles

If build fails on something other than the patterns above, that's almost always a third-party package that hasn't been released against Feliz 3 yet — check its release notes.

Tips

  • Commit before running upgrade. It's the easiest way to review exactly what changed.
  • Re-run upgrade after manually editing. It's a no-op on already-upgraded code, and any new manual-migration warnings will surface again.