Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions docs/docs/ecosystem/04_Hooks/Feliz.UseElmish.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ sidebar_position: 9

import ComponentRender from '@site/src/components/ComponentRender';
import CodeBlock from '@theme/CodeBlock';
import BrowserOnly from '@docusaurus/BrowserOnly';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you correctly handle the getServerSnapshot variable it should work without BrowserOnly component https://react.dev/reference/react/useSyncExternalStore

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optional getServerSnapshot: A function that returns the initial snapshot of the data in the store. It will be used only during server rendering and during hydration of server-rendered content on the client. The server snapshot must be the same between the client and the server, and is usually serialized and passed from the server to the client. If you omit this argument, rendering the component on the server will throw an error

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this was from an earlier test to just show the docs before I attempted making useElmish for SSG. Updated docs to remove duplicate and import.


Besides being able to use Feliz in existing Elmish applications, you can also use Elmish as _part_ of your Feliz application. This is a different approach to building standalone React components that use Elmish internally to manage the state of the component but from the perspective of the consumer, it is just another React component.

Expand All @@ -30,11 +31,18 @@ Unable to show live example because Feliz.UseElmish does not support Server-Side

Here is an example to demonstrate how to build such component:

import ElmishCounter from '../../feliz-docs/fableoutput/Examples/React/ElmishCounter'
import RawElmishCounter from '!!raw-loader!../../feliz-docs/Examples/React/ElmishCounter.fs'

<CodeBlock language="fsharp" showLineNumbers>
{RawElmishCounter}
</CodeBlock>
<ComponentRender code={RawElmishCounter}><ElmishCounter /></ComponentRender>

Copilot AI Dec 19, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation shows duplicate ComponentRender elements rendering the same component. Line 37 should be removed as it's redundant with the BrowserOnly wrapped version below it. The BrowserOnly wrapper is the correct approach for components that don't support SSR.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only single one now



<BrowserOnly fallback={<div>Loading in browser...</div>} >
{() =>
<ComponentRender code={RawElmishCounter}><ElmishCounter /></ComponentRender>
}
</BrowserOnly>


The difference here from a full-fledged Elmish applications is that there isn't an "Elmish entry point" to run the component and manage its life-cycle. Instead, the `React.useElmish` hooks manages the Elmish life-cycle internally within the React component so that it can run standalone inside other React components:

Expand Down
3 changes: 3 additions & 0 deletions src/Feliz.UseElmish/Feliz.UseElmish.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,7 @@
<ItemGroup>
<PackageReference Include="Fable.Elmish"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Feliz\Feliz.fsproj" />
</ItemGroup>
</Project>
22 changes: 7 additions & 15 deletions src/Feliz.UseElmish/UseElmish.fs
Original file line number Diff line number Diff line change
@@ -1,19 +1,10 @@
module Feliz.UseElmish

open System
open Fable.Core
open Elmish

module private Util =
type UseSyncExternalStoreSubscribe = delegate of (unit -> unit) -> (unit -> unit)

[<ImportMember("react")>]
let useSyncExternalStore(subscribe: UseSyncExternalStoreSubscribe, getSnapshot: unit -> 'Model, getServerSnapshot: (unit -> 'Model) option): 'Model = jsNative

[<ImportMember("react")>]
let useState(init: unit -> 'State): 'State * ('State -> unit) = jsNative

[<ImportMember "react">]
let useEffect(effect: unit -> unit, dependencies: obj array) : unit = jsNative

[<Emit "setTimeout($0)">]
let setTimeout(callback: unit -> unit) : unit = jsNative
Expand Down Expand Up @@ -47,7 +38,7 @@ module private Util =
let subscribed = false
(model, initialDispatch, subscribed, queuedMessages), cmd

let subscribe = UseSyncExternalStoreSubscribe(fun callback ->
let subscribe = FsReact.createSyncExternalStoreSubscribe(fun callback ->
// printfn "Subscribing %O..." guid
let mutable dispose = false
// needsDispose is used to determine whether the model inside state needs to be disposed of when the subscription is terminated.
Expand Down Expand Up @@ -105,21 +96,22 @@ module private Util =
member _.IsOutdated(arg', dependencies') = arg <> arg' || dependencies <> dependencies'

open Util
open Feliz

[<Erase>]
type React =
static member useElmish(program: unit -> Program<'Arg, 'Model, 'Msg, unit>, arg: 'Arg, ?dependencies: obj array): 'Model * ('Msg -> unit) =
let state, setState = useState(fun () -> ElmishState(program, arg, dependencies))
let state, setState = React.useState(fun () -> ElmishState(program, arg, dependencies))
if state.IsOutdated(arg, dependencies) then
ElmishState(program, arg, dependencies) |> setState
let finalState, dispatch, subscribed, queuedMessages = useSyncExternalStore(state.Subscribe, (fun () -> state.State), None)
let finalState, dispatch, subscribed, queuedMessages = React.useSyncExternalStore(state.Subscribe, UseSyncExternalStoreSnapshot(fun () -> state.State), UseSyncExternalStoreSnapshot(fun () -> state.State))

Copilot AI Dec 19, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same getServerSnapshot value (state.State) is being used for both the client and server. This defeats the purpose of SSG support. For SSG to work correctly, getServerSnapshot should typically return a stable initial state or handle the case differently from the client-side snapshot. Using the same value for both parameters means SSG/SSR will still encounter the same issues this PR is meant to fix.

Suggested change
let finalState, dispatch, subscribed, queuedMessages = React.useSyncExternalStore(state.Subscribe, UseSyncExternalStoreSnapshot(fun () -> state.State), UseSyncExternalStoreSnapshot(fun () -> state.State))
let initialState = state.State
let finalState, dispatch, subscribed, queuedMessages =
React.useSyncExternalStore(
state.Subscribe,
UseSyncExternalStoreSnapshot(fun () -> state.State),
UseSyncExternalStoreSnapshot(fun () -> initialState)
)

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From my tests and understanding this should be fine as the initial value should always be the same that is passed in from the init function.

// Run any queued messages that were dispatched before the Elmish program finished subscribing
useEffect((fun () ->
React.useEffect((fun () ->
if subscribed && queuedMessages.Count > 0 then
for msg in queuedMessages do
setTimeout(fun () -> dispatch msg)
queuedMessages.Clear()
), [| subscribed; queuedMessages |])
), [| box subscribed; box queuedMessages |])
finalState, dispatch

static member inline useElmish(program: unit -> Program<unit, 'Model, 'Msg, unit>, ?dependencies: obj array) =
Expand Down
3 changes: 3 additions & 0 deletions src/Feliz/React/FsReact.fs
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,6 @@ type FsReact =
)
)
token

static member inline createSyncExternalStoreSubscribe(subscribe: (unit -> unit) -> (unit -> unit)) : UseSyncExternalStoreSubscribe =
UseSyncExternalStoreSubscribe(subscribe)

Copilot AI Dec 19, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a helper function to create UseSyncExternalStoreSnapshot delegate, similar to createSyncExternalStoreSubscribe, to provide a consistent API for users who need to create these delegates manually.

Suggested change
UseSyncExternalStoreSubscribe(subscribe)
UseSyncExternalStoreSubscribe(subscribe)
static member inline createSyncExternalStoreSnapshot<'Snapshot>(getSnapshot: unit -> 'Snapshot) : UseSyncExternalStoreSnapshot<'Snapshot> =
UseSyncExternalStoreSnapshot(getSnapshot)

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've removed the helper as it seemed unnecessary and more verbose

10 changes: 5 additions & 5 deletions src/Feliz/React/React.fs
Original file line number Diff line number Diff line change
Expand Up @@ -636,25 +636,25 @@ useLayoutEffect(() => {
/// <param name='getSnapshot'>A function that returns the current value of the external data source.</param>
/// <returns>The current value from the external data source.</returns>
[<ImportMember("react")>]
static member inline useSyncExternalStore(subscribe: Func<(unit -> unit),(unit -> unit)> , getSnapshot: unit -> 'T, ?getServerSnapshot: unit -> 'T): 'T = jsNative
static member inline useSyncExternalStore(subscribe: UseSyncExternalStoreSubscribe, getSnapshot: UseSyncExternalStoreSnapshot<'T>, ?getServerSnapshot: UseSyncExternalStoreSnapshot<'T>): 'T = jsNative

/// <summary>
/// Subscribes to a data source, and returns the current value from it.
/// </summary>
/// <param name='subscribe'>A function that sets up a subscription to the external data source. It receives a callback to be called when the data source changes.</param>
/// <param name='getSnapshot'>A function that returns the current value of the external data source.</param>
/// <returns>The current value from the external data source.</returns>
static member inline useSyncExternalStore(subscribe: (unit -> unit) -> (unit -> unit), getSnapshot: unit -> 'T, ?getServerSnapshot: unit -> 'T): 'T =
React.useSyncExternalStore( Func<_,_> subscribe, getSnapshot, ?getServerSnapshot = getServerSnapshot)
static member inline useSyncExternalStore(subscribe: (unit -> unit) -> (unit -> unit), getSnapshot: UseSyncExternalStoreSnapshot<'T>, ?getServerSnapshot: UseSyncExternalStoreSnapshot<'T>): 'T =
React.useSyncExternalStore( UseSyncExternalStoreSubscribe subscribe, getSnapshot, ?getServerSnapshot = getServerSnapshot)
Comment on lines +647 to +648

Copilot AI Dec 19, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the overload for useSyncExternalStore that accepts an F# function signature, the getSnapshot parameter should also be converted to the delegate type. Currently, it's expecting UseSyncExternalStoreSnapshot<'T> but when calling with a function, the caller would need to wrap it in the delegate. Consider adding an overload that accepts (unit -> 'T) for getSnapshot and converts it internally, similar to how subscribe is handled.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this works with the optional parameter. Not really sure how it works with the question mark on the sender side.


/// <summary>
/// Subscribes to a data source, and returns the current value from it.
/// </summary>
/// <param name='subscribe'>A function that sets up a subscription to the external data source. It receives a callback to be called when the data source changes.</param>
/// <param name='getSnapshot'>A function that returns the current value of the external data source.</param>
/// <returns>The current value from the external data source.</returns>
static member inline useSyncExternalStore(subscribe: (unit -> unit) -> #IDisposable, getSnapshot: unit -> 'T, ?getServerSnapshot: unit -> 'T): 'T =
React.useSyncExternalStore( Func<_,_>
static member inline useSyncExternalStore(subscribe: (unit -> unit) -> #IDisposable, getSnapshot: UseSyncExternalStoreSnapshot<'T>, ?getServerSnapshot: UseSyncExternalStoreSnapshot<'T>): 'T =
React.useSyncExternalStore( UseSyncExternalStoreSubscribe
(fun (callback) ->
let disp = subscribe(callback)
fun () -> disp.Dispose()
Expand Down
3 changes: 3 additions & 0 deletions src/Feliz/React/ReactTypes.fs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ module ReactTypes =

type ReactNode = U6<ReactElement, seq<ReactElement>, string, float, int, bool>

type UseSyncExternalStoreSubscribe = delegate of (unit -> unit) -> (unit -> unit)
type UseSyncExternalStoreSnapshot<'T> = delegate of unit -> 'T

[<Erase>]
type IReactRoot =
/// Renders the provided React element into the DOM in the supplied container.
Expand Down
1 change: 0 additions & 1 deletion tests/Feliz/ReactBindings/UseSyncExternalStore.test.fs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ type Components =
snapshot
)


Html.div [

]
Expand Down