useFetch Hook
useFetch
is a custom hook in Fluxus that provides a convenient way to fetch data from APIs with built-in loading states, error handling, and JSON decoding.
Basic Usage
val (state, retry) = useFetch[T](
url = "https://api.example.com/data",
dependencies = Seq(dependency1, dependency2),
options = FetchOptions(/* configuration */)
)
Where: - T
is the expected type of the response data (must have a JsonDecoder
instance) - state
is a FetchState[T]
representing the current state of the fetch - retry
is a function that can be called to retry the fetch - dependencies
is an optional array of values that will trigger a refetch when changed - options
is an optional configuration object for the fetch request
FetchState
The state of a fetch operation is represented by the FetchState
enum:
enum FetchState[T]:
case Success(data: T)
case Error(error: FetchError)
case Idle()
case Loading()
FetchError
Errors are represented by the FetchError
enum:
enum FetchError extends Throwable:
case NetworkError(message: String)
case DecodeError(message: String)
case HttpError(status: Int, statusText: String)
FetchOptions
The FetchOptions
case class allows you to configure the fetch request:
case class FetchOptions(
method: String = "GET",
headers: Map[String, String] = Map.empty,
body: Option[String] = None,
retries: Int = 3,
retryDelay: Int = 1000,
mode: String = "cors"
)
Examples
Basic Data Fetching
case class User(id: Int, name: String, email: String) derives JsonDecoder
val UserProfile = (props: UserProfileProps) => {
val (state, retry) = useFetch[User](
url = s"https://api.example.com/users/${props.userId}",
dependencies = Seq(props.userId)
)
div(
cls := "user-profile",
state match {
case FetchState.Loading() =>
div(cls := "loading", "Loading user data...")
case FetchState.Success(user) =>
div(
h1(user.name),
p(user.email),
button(
onClick := (() => retry()),
"Refresh"
)
)
case FetchState.Error(error) =>
div(
cls := "error",
p(s"Error: ${error.getMessage}"),
button(
onClick := (() => retry()),
"Retry"
)
)
case FetchState.Idle() =>
div("No user data loaded yet")
}
)
}
Fetching a List
case class TodoItem(id: Int, title: String, completed: Boolean) derives JsonDecoder
val TodoList = () => {
val (state, retry) = useFetch[List[TodoItem]](
url = "https://jsonplaceholder.typicode.com/todos",
options = FetchOptions(
headers = Map(
"Accept" -> "application/json"
)
)
)
div(
h1("Todo List"),
state match {
case FetchState.Loading() =>
div(cls := "loading-spinner", "Loading todos...")
case FetchState.Success(todos) =>
div(
p(s"${todos.size} todos loaded"),
ul(
cls := "todo-list",
todos.take(10).map(todo =>
li(
key := todo.id.toString,
input(
typ := "checkbox",
checked := todo.completed,
disabled := true
),
span(todo.title)
)
)
),
button(
onClick := (() => retry()),
"Refresh"
)
)
case FetchState.Error(error) =>
div(
cls := "alert alert-error",
p(s"Failed to load todos: ${error.getMessage}"),
button(
onClick := (() => retry()),
"Retry"
)
)
case FetchState.Idle() =>
div("Click to load todos")
}
)
}
POST Request with JSON Body
case class LoginRequest(username: String, password: String) derives JsonEncoder
case class LoginResponse(token: String, user: User) derives JsonDecoder
val LoginForm = () => {
val (username, setUsername, _) = useState("")
val (password, setPassword, _) = useState("")
val (loginState, login) = useFetch[LoginResponse](
url = "", // Empty URL initially, will be set when logging in
dependencies = Seq()
)
def handleSubmit(e: dom.Event): Unit = {
e.preventDefault()
// Create a new fetch with the login data
val loginData = LoginRequest(username, password)
login(
url = "https://api.example.com/login",
options = FetchOptions(
method = "POST",
headers = Map(
"Content-Type" -> "application/json"
),
body = Some(loginData.toJson)
)
)
}
div(
form(
onSubmit := handleSubmit,
div(
label("Username"),
input(
typ := "text",
value := username,
onInput := ((e: dom.Event) =>
setUsername(e.target.asInstanceOf[dom.html.Input].value)
)
)
),
div(
label("Password"),
input(
typ := "password",
value := password,
onInput := ((e: dom.Event) =>
setPassword(e.target.asInstanceOf[dom.html.Input].value)
)
)
),
loginState match {
case FetchState.Loading() =>
button(
typ := "submit",
disabled := true,
"Logging in..."
)
case FetchState.Error(error) =>
div(
cls := "error",
p(s"Login failed: ${error.getMessage}"),
button(typ := "submit", "Try Again")
)
case _ =>
button(typ := "submit", "Log In")
}
),
loginState match {
case FetchState.Success(response) =>
div(
cls := "success",
p(s"Welcome, ${response.user.name}!"),
p(s"Your token: ${response.token}")
)
case _ => null
}
)
}
Implementing Pagination
case class PaginatedResponse[T](
items: List[T],
totalItems: Int,
page: Int,
totalPages: Int
) derives JsonDecoder
case class Product(id: Int, name: String, price: Double) derives JsonDecoder
val ProductList = () => {
val (page, setPage, _) = useState(1)
val (state, retry) = useFetch[PaginatedResponse[Product]](
url = s"https://api.example.com/products?page=$page&limit=10",
dependencies = Seq(page)
)
div(
h1("Products"),
state match {
case FetchState.Loading() =>
div(cls := "loading-spinner", "Loading products...")
case FetchState.Success(response) =>
div(
table(
cls := "products-table",
thead(
tr(
th("ID"),
th("Name"),
th("Price")
)
),
tbody(
response.items.map(product =>
tr(
key := product.id.toString,
td(product.id.toString),
td(product.name),
td(f"$${product.price}%.2f")
)
)
)
),
div(
cls := "pagination",
button(
onClick := (() => setPage(page - 1)),
disabled := (page <= 1),
"Previous"
),
span(s"Page $page of ${response.totalPages}"),
button(
onClick := (() => setPage(page + 1)),
disabled := (page >= response.totalPages),
"Next"
)
)
)
case FetchState.Error(error) =>
div(
cls := "error",
p(s"Error: ${error.getMessage}"),
button(
onClick := (() => retry()),
"Retry"
)
)
case FetchState.Idle() =>
div("No products loaded yet")
}
)
}
Advanced Features
Automatic Retries
useFetch
automatically retries failed requests based on the retries
and retryDelay
options:
val (state, retry) = useFetch[User](
url = "https://api.example.com/user/123",
options = FetchOptions(
retries = 5, // Retry up to 5 times
retryDelay = 500 // Wait 500ms between retries
)
)
By default, it will retry network errors and server errors (5xx), but not client errors (4xx) or decode errors.
Manual Retry
You can manually retry a fetch with the returned retry
function:
button(
onClick := (() => retry()),
"Reload Data"
)
Cancellation
useFetch
automatically cancels in-flight requests when: - The component unmounts - The dependencies change, triggering a new fetch - You manually trigger a new fetch
This prevents race conditions and memory leaks.
TypeScript/Scala Integration
The useFetch
hook is fully typed and integrates with Scala’s type system. The type parameter T
ensures that:
- The JSON response is decoded to the correct Scala type
- The
Success
case ofFetchState
contains a value of typeT
- The compiler can check that you’re handling all possible states
For this to work, your data types need to have a JsonDecoder
instance, which can be derived using the derives
keyword with ZIO JSON:
case class Person(name: String, age: Int) derives JsonDecoder
Implementation Details
Under the hood, useFetch
uses a combination of:
useState
to track the fetch stateuseEffect
to trigger the fetch and handle cleanupjs.Promise
for the actual network request- ZIO JSON for decoding the response
The hook also handles common edge cases like: - Race conditions between multiple in-flight requests - Component unmounting during a fetch - Network errors and timeout handling - JSON decoding errors