Fable hybrid with Fable.Remoting and a normal SPA client

August 17, 2023

Let's start with a little image showing the components to create:

illustration showing a server, a client a shared module and a fable client
  1. The Server is just a normal F# server, using ASP.NET Core / Fable.Remoting, implementing a simple counter.
  2. The F# Shared code project contains code that will be shared between the Fable Client and F# Server projects and also contains the abstract API definition. On the server this code will be referenced as is.
  3. The F# Fable client project contains the code that will be translated to TypeScript and used in the React application. The shared project will also be translated, as it is referenced and used by the F# Fable Client module.
  4. The "Native Client" project is in our case a normal TypeScript/React application that uses the generated TypeScript code from the F# Fable client. The native client uses the generated code to communicate with the server in a strongly typed fashion.

Setting up the projects

After executing the following commands we should have set up the 4 projects with correct dependencies:


npm create vite@latest Client -- --template react-ts
cd Client
npm install prettier --save-dev
npm install @tanstack/react-query@beta
cd ..
dotnet new sln --name fable-hybrid
dotnet new classlib -lang F# --name FableClient
dotnet new console -lang F# --name Server
dotnet new classlib -lang F# --name Shared
dotnet sln add ./Server/Server.fsproj
dotnet sln add ./Shared/Shared.fsproj
dotnet sln add ./FableClient/FableClient.fsproj
dotnet add ./Server/Server.fsproj reference ./Shared/Shared.fsproj
dotnet add ./FableClient/FableClient.fsproj reference ./Shared/Shared.fsproj
dotnet add ./FableClient/FableClient.fsproj package Fable.Remoting.Client
dotnet add ./Server/Server.fsproj package Fable.Remoting.AspNetCore
dotnet add ./Server/Server.fsproj package Microsoft.AspNetCore.SpaServices.Extensions

Implement shared API definition

In the shared project code that should be available in the server and in the client can be added. We will just define a simple API with two functions, one to get some server side state and one to modify it:

./Shared/Library.fs
Copy

module Shared
type Server =
{ count: unit -> Async<int>
increment: unit -> Async<unit> }

Implement the server

In ./Server/Server.fsproj set the SDK property to Microsoft.NET.Sdk.Web so that ASP.NET core is implicitly included:


<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Shared\Shared.fsproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Fable.Remoting.AspNetCore" Version="2.33.0" />
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="7.0.9" />
</ItemGroup>
</Project>

Then in Program.fs we provide the implementation for the Api type, set up Fable Remoting and use the Spa middleware to serve the client files via our server.

./Server/Program.fs
Copy

open System
open Microsoft.AspNetCore.Builder
open Microsoft.Extensions.Hosting
open Fable.Remoting.Server
open Fable.Remoting.AspNetCore
open Microsoft.AspNetCore.Hosting
open Server.GameServer
[<EntryPoint>]
let main args =
let mutable count = 0
let server: Server =
{ count = fun () -> async { return count }
increment = fun () -> async { count <- count + 1 } }
let builder = WebApplication.CreateBuilder(args)
let app = builder.Build()
let webApp = Remoting.createApi () |> Remoting.fromValue server
app.UseRemoting webApp
app.UseStaticFiles() |> ignore
app.UseSpa(fun o -> o.UseProxyToSpaDevelopmentServer("http://localhost:5173/"))
app.Run()
0 // Exit code

For production usage the client should be build upfront and served from the file system. For simplicity here we will just run the frontend with the vite dev webserver and proxy requests coming to our backend to this dev server (if they are not handled previously in the ASP.NET core middleware pipeline).

Implement the FableClient and generate TypeScript code

Now in the FableClient project we will instantiate an API proxy with Fable.Remoting. Additionally we will retype all available API functions and convert them to promises so that they can be used in normal JavaScript code.

./FableClient/Api.fs
Copy

module FableClient
open Shared
open Fable.Remoting.Client
open Fable.Core
let server: Server =
Remoting.createApi ()
|> Remoting.buildProxy<Server>
let count () = server.count () |> Async.StartAsPromise
let increment () = server.increment () |> Async.StartAsPromise

As our server count and server increment functions on the server proxy object are asynchronous (they have the type unit -> Async<int> and unit -> Async<unit>), we need to use the Async.StartAsPromise function to convert them to a Promise that can be used in normal JavaScript code. Also this code will add TypeScript annotations to the functions, which somehow does not happen on the proxy object if we would try to use it directly.

All F# files must be explicitly added in the corresponding fsproj file:


<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<Compile Include="Api.fs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Shared\Shared.fsproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Fable.Remoting.Client" Version="7.25.0" />
</ItemGroup>
</Project>

In the FableClient project we need to add Fable as a dotnet tool, and run it to compile the F# code to TypeScript:


cd FableClient
dotnet new tool-manifest
dotnet tool install fable
dotnet fable watch --lang ts -o ../Client/src/api/

When invoking dotnet fable we will instruct fable to generate the client code. Here I start fable in watch mode, so any changes to the F# code would automatically trigger recompilation.

The language is specified via the --lang argument and the -o argument specifies that we want the generated code to be placed in our native Client project.

We don't necessarily need to split the Fable client and native client, however my experience with having both in the same project was not especially great. Somehow the generated filenames are different when not specifying the output folder. In my case the generated file for the Api was Api.fs.ts (lying next to Api.fs). When importing this file using the name without the ts prefix I needed to import Api.fs which then really imported the F# file. Importing Api.fs.js seems to work, but its akward as this file does not exist in the project. So, it works, but I did not really like it.

After runnign dotnet fable the following code that was generated:

./Client/api/Api.ts
Copy

import { Remoting_buildProxy_64DC51C } from "./fable_modules/Fable.Remoting.Client.7.25.0/Remoting.fs.js"
import { RemotingModule_createApi } from "./fable_modules/Fable.Remoting.Client.7.25.0/Remoting.fs.js"
import { Server, Server_$reflection } from "./Shared/Library.js"
import { startAsPromise } from "./fable_modules/fable-library-ts/Async.js"
import { int32 } from "./fable_modules/fable-library-ts/Int32.js"
export const server: Server = Remoting_buildProxy_64DC51C<Server>(
RemotingModule_createApi(),
Server_$reflection()
)
export function count(): Promise<int32> {
return startAsPromise(server.count())
}
export function increment(): Promise<void> {
return startAsPromise(server.increment())
}

These functions have proper types and can be easily consumed in the rest of our client application.

Implement the native client

Let's prepare our native client so that we can use the state/query/mutation management library react-query. We will also make use of React.Suspense and ErrorBoundaries to enable suspensfull data loading and error handling. In this way in our component we will only deal with the "happy path" (data was successfuylly loaded), and place loading and error handling one step up in the component tree:

./Client/main.tsx
Copy

import React from "react"
import ReactDOM from "react-dom/client"
import { App } from "./App.tsx"
import "./index.css"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ErrorBoundary } from "react-error-boundary"
const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<React.Suspense fallback={<div>Loading...</div>}>
<App />
</React.Suspense>
</ErrorBoundary>
</QueryClientProvider>
</React.StrictMode>
)

Then in our App.tsx we can use useSuspenseQuery and useMutation and combine them with our generated increment and count functions to fetch the data and mutate it:

./Client/src/App.tsx
Copy

import "./App.css"
import { useMutation, useSuspenseQuery } from "@tanstack/react-query"
import { increment, count } from "./api/Api"
export function App() {
// data has type number here
const { data, refetch } = useSuspenseQuery({
queryKey: ["count"],
queryFn: count,
})
const incrementMutation = useMutation({
mutationFn: increment,
onSuccess: () => refetch(),
})
return (
<div className="card">
<button onClick={() => incrementMutation.mutate()}>
count is {data}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
)
}

Run and test

Now its time to start our server and client


cd Server
dotnet watch run --project .\Server\Server.fsproj

and in another session


cd Client
npm run dev

Then in a browser you can open http://localhost:5000 where the backend is running, proxying the request to the fronted dev server. The counter button should be shown and should work now.

Closing thoughts

I think this approach could be used in projects where the frontend folks don't want to write F# or where the F# knowledge is not yet so strong to go the full F# way (i.e. doing with Feliz and Elmish). Also with this approach there is no need to write bindings, but we still have everything strongly typed!