Fable hybrid with Fable.Remoting and a normal SPA client
tl;dr
Let's start with a little image showing the components to create:
- The Server is just a normal F# server, using ASP.NET Core / Fable.Remoting, implementing a simple counter.
- 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.
- 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.
- 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-tscd Clientnpm install prettier --save-devnpm install @tanstack/react-query@betacd ..dotnet new sln --name fable-hybriddotnet new classlib -lang F# --name FableClientdotnet new console -lang F# --name Serverdotnet new classlib -lang F# --name Shareddotnet sln add ./Server/Server.fsprojdotnet sln add ./Shared/Shared.fsprojdotnet sln add ./FableClient/FableClient.fsprojdotnet add ./Server/Server.fsproj reference ./Shared/Shared.fsprojdotnet add ./FableClient/FableClient.fsproj reference ./Shared/Shared.fsprojdotnet add ./FableClient/FableClient.fsproj package Fable.Remoting.Clientdotnet add ./Server/Server.fsproj package Fable.Remoting.AspNetCoredotnet 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:
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.
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.
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 FableClientdotnet new tool-manifestdotnet tool install fabledotnet 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:
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:
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:
Run and test
Now its time to start our server and client
cd Serverdotnet watch run --project .\Server\Server.fsproj
and in another session
cd Clientnpm 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!