{"id":13778905,"url":"https://github.com/fmo91/Pigeon","last_synced_at":"2025-05-11T12:32:13.199Z","repository":{"id":47730121,"uuid":"289801738","full_name":"fmo91/Pigeon","owner":"fmo91","description":"Async state management for SwiftUI (and UIKit) 🐦","archived":false,"fork":false,"pushed_at":"2021-08-01T02:15:54.000Z","size":3452,"stargazers_count":415,"open_issues_count":13,"forks_count":12,"subscribers_count":5,"default_branch":"master","last_synced_at":"2024-10-11T21:43:42.417Z","etag":null,"topics":["combine","ios","networking","swift","swiftui"],"latest_commit_sha":null,"homepage":"","language":"Swift","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/fmo91.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2020-08-24T01:48:50.000Z","updated_at":"2024-10-09T12:02:11.000Z","dependencies_parsed_at":"2022-08-24T13:37:21.353Z","dependency_job_id":null,"html_url":"https://github.com/fmo91/Pigeon","commit_stats":null,"previous_names":[],"tags_count":18,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fmo91%2FPigeon","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fmo91%2FPigeon/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fmo91%2FPigeon/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fmo91%2FPigeon/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/fmo91","download_url":"https://codeload.github.com/fmo91/Pigeon/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":253566968,"owners_count":21928758,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["combine","ios","networking","swift","swiftui"],"created_at":"2024-08-03T18:00:58.790Z","updated_at":"2025-05-11T12:32:12.015Z","avatar_url":"https://github.com/fmo91.png","language":"Swift","funding_links":[],"categories":["State"],"sub_categories":["Content"],"readme":"# Pigeon 🐦\n\n[![CI Status](https://img.shields.io/travis/fmo91/Pigeon.svg?style=flat)](https://travis-ci.org/fmo91/Pigeon)\n[![Version](https://img.shields.io/cocoapods/v/Pigeon.svg?style=flat)](https://cocoapods.org/pods/Pigeon)\n[![License](https://img.shields.io/cocoapods/l/Pigeon.svg?style=flat)](https://cocoapods.org/pods/Pigeon)\n[![Platform](https://img.shields.io/cocoapods/p/Pigeon.svg?style=flat)](https://cocoapods.org/pods/Pigeon)\n[![Slack](https://img.shields.io/badge/slack-@Pigeon-green.svg?logo=slack)](https://join.slack.com/t/pigeonespacio/shared_invite/zt-h2t3fxec-ZHuhC6WhlKB3ZWh6X89g4g) \n\n## Introduction\n\nPigeon is a SwiftUI and UIKit library that relies on Combine to deal with asynchronous data. It is heavily inspired by [React Query](https://react-query.tanstack.com/).\n\n## In a nutshell\n\nWith Pigeon you can:\n\n- Fetch server side APIs.\n- Cache server responses using interchangeable and configurable cache providers.\n- Share server data among different, unconnected components in your app.\n- Mutate server side resources.\n- Invalidate cache and refetch data.\n- Manage paginated data sources\n- Pigeon is agnostic on what you use for fetching data.\n\nAll of that working against a very simple interface that uses the very convenient `ObservableObject` Combine protocol.\n\n## What is Pigeon?\n\nPigeon is all about Queries and Mutations. Queries are objects that are responsible of fetching server data, and Mutations are objects that are responsible of modifying server data. Both Queries and Mutations are `ObservableObject` conforming, meaning both of them are fully compatible with SwiftUI and that their states are observable.\n\nQueries are identified by a `QueryKey`. Pigeon uses `QueryKey` objects to cache query results, link them internally and invalidate queries when they need to be refetched.\n\nA very important thing in Pigeon is that you can use whatever you want to fetch data from wherever you need. Pigeon don't force you to use `Alamofire` or `URLSession` or `GraphQL` or even `CoreData`. You can fetch the data from where you need using the most appropriate tool. The only thing you need to use is `Combine` publishers.\n\nThe last thing I want to note and then we can go straight to code. Pigeon can optionally cache your responses: you can let Pigeon store the responses for your fetches and it will populate your app with data with almost zero-config.\n\n## Quick Start\n\nIn the core of Pigeon is the `Query` ObservableObject. Let's explore the 'hello world' of Pigeon:\n\n```swift\n// 1\nstruct User: Codable, Identifiable {\n    let id: Int\n    let name: String\n}\n\nstruct UsersList: View {\n    // 2\n    @ObservedObject var users = Query\u003cVoid, [User]\u003e(\n        // 3    \n        key: QueryKey(value: \"users\"),\n        // 4\n        fetcher: {\n            URLSession.shared\n                .dataTaskPublisher(for: URL(string: \"https://jsonplaceholder.typicode.com/users\")!)\n                .map(\\.data)\n                .decode(type: [User].self, decoder: JSONDecoder())\n                .receive(on: DispatchQueue.main)\n                .eraseToAnyPublisher()\n        }\n    )\n    \n    var body: some View {\n        // 5\n        List(users.state.value ?? []) { user in\n            Text(user.name)\n        }.onAppear(perform: {\n            // 6\n            self.users.refetch(request: ())\n        })\n    }\n}\n```\n\n1. We start by defining a `Codable` structure that will store our server side data. This is not related to `Pigeon` itself, but is still needed for our example to work.\n2. We define a `Query` that will store our array of `User`. `Query` takes two generic parameters: `Request` (`Void` in this example, since the fetch action won't receive any parameters) and `Response` which is the type of our data (`[User]` in this example).\n3. Data is cached by default in Pigeon. The `QueryKey` is a simple wrapper around the `String` that identifies our piece of state.\n4. `Query` also receives a `fetcher`, which is a function that we have to define. `fetcher` takes the `Request` and returns a Combine `Publisher` holding the `Response`. Note that we can put whatever custom logic in the `fetcher`. In this case, we use `URLSession` to get an array of `User` from an API.\n5. `Query` contains a state, that is either: `idle` (if it just starts), `loading` (if the fetcher is running), `failed` (which also contains an `Error`), or `succeed` (which also contains the `Response`). `value` is just a convenience property that returns a `Response` in case it exists, or `nil` otherwise.\n\n```swift\n// ...\n    var body: some View {\n        // 5\n        switch users.state {\n            case .idle, .loading:\n                return AnyView(Text(\"Loading...\"))\n            case .failed:\n                return AnyView(Text(\"Oops...\"))\n            case let .succeed(users):\n                return AnyView(\n                    List(users) { user in\n                        Text(user.name)\n                    }\n                )\n        }\n    }\n// ...\n```\n\n**Note: If you find this ugly, then you might be interested in `QueryRenderer`. Keep scrolling!**\n\n6. In this example, we are firing our `Query` manually, using `refetch`. However, we can also configure our `Query` so it fires immediately like this:\n\n```swift\nstruct UsersList: View {\n    @ObservedObject var users = Query\u003cVoid, [User]\u003e(\n        key: QueryKey(value: \"users\"),\n        // Changing the query behavior, we can tell the query to \n        // start fetching as soon as it initializes. \n        behavior: .startImmediately(()),\n        fetcher: {\n            URLSession.shared\n                .dataTaskPublisher(for: URL(string: \"https://jsonplaceholder.typicode.com/users\")!)\n                .map(\\.data)\n                .decode(type: [User].self, decoder: JSONDecoder())\n                .receive(on: DispatchQueue.main)\n                .eraseToAnyPublisher()\n        }\n    )\n    \n    var body: some View {\n        List(users.state.value ?? []) { user in\n            Text(user.name)\n        }\n    }\n}\n```\n\n## Queries and Query Consumers\n\nIn addition to Queries, Pigeon has another type, `Consumer` that doesn't provide any kind of fetching capability, but just provides the capability to consume, and react to changes in Queries with the same `QueryKey` that it subscribes to. Please note that the `Query` dependency injection is done internally, and that the state is not duplicated.\n\n```swift\nstruct ContentView: View {\n    @ObservedObject var users = Query\u003cVoid, [User]\u003e(\n        key: QueryKey(value: \"users\"),\n        behavior: .startImmediately(()),\n        fetcher: {\n            URLSession.shared\n                .dataTaskPublisher(for: URL(string: \"https://jsonplaceholder.typicode.com/users/\")!)\n                .map(\\.data)\n                .decode(type: [User].self, decoder: JSONDecoder())\n                .receive(on: DispatchQueue.main)\n                .eraseToAnyPublisher()\n        }\n    )\n    \n    var body: some View {\n        UsersList()\n    }\n}\n\nstruct UsersList: View {\n    @ObservedObject var users = Query\u003cVoid, [User]\u003e.Consumer(key: QueryKey(value: \"users\"))\n    \n    var body: some View {\n        List(users.state.value ?? []) { user in\n            Text(user.name)\n        }\n    }\n}\n\nstruct User: Codable, Identifiable {\n    let id: Int\n    let name: String\n}\n```\n\n## Polling\n\nPigeon provides a way to fetching data using the fetcher every N seconds. That's achieved with the `pollingBehavior` property in the `Query` class. Default is `.noPolling`. Let's see an example:\n\n```swift\n@ObservedObject var users = Query\u003cVoid, [User]\u003e(\n    key: QueryKey(value: \"users\"),\n    behavior: .startImmediately(()),\n    pollingBehavior: .pollEvery(2),\n    fetcher: {\n        URLSession.shared\n            .dataTaskPublisher(for: URL(string: \"https://jsonplaceholder.typicode.com/users\")!)\n            .map(\\.data)\n            .decode(type: [User].self, decoder: JSONDecoder())\n            .receive(on: DispatchQueue.main)\n            .eraseToAnyPublisher()\n    }\n)\n```\n\nThat query will trigger its fetcher every 2 seconds.\n\n## Mutations\n\nIn addition to allow queries, Pigeon also provides a way to mutate server data, and force to refetch affected queries.\n\n```swift\n@ObservedObject var sampleMutation = Mutation\u003cInt, User\u003e { (number) -\u003e AnyPublisher\u003cUser, Error\u003e in\n    Just(User(id: number, name: \"Pepe\"))\n        .tryMap({ $0 })\n        .eraseToAnyPublisher()\n}\n\n// ...\n\nsampleMutation.execute(with: 10) { (user: User, invalidate) in\n    // Invalidate triggers a new query on the \"users\" key\n    invalidate(QueryKey(value: \"users\"), .lastData)\n}\n```\n\n## Convenient keys\n\nYou can also define more convenient keys by extending `QueryKey` like this:\n\n```swift\nextension QueryKey {\n    static let users: QueryKey = QueryKey(value: \"users\")\n}\n```\n\nSo then you can use it like this:\n\n```swift\nstruct UsersList: View {\n    @ObservedObject var users = Query\u003cVoid, [User]\u003e.Consumer(key: .users)\n    \n    var body: some View {\n        List(users.state.value ?? []) { user in\n            Text(user.name)\n        }\n    }\n}\n```\n\n## Key adapters\n\nThere are some times where you need to cache values not only depending on your `Query` type, but also on the parameters of your request. For instance, maybe you want to cache the response for user with id=1 in a separate cache value than user with id=2. That is the problem key adapters solve. Key Adapters are available both in `Query` and in `PaginatedQuery` and are optional.\nKey adapters are sent under the `keyAdapter` parameter for the constructor and are functions with `(QueryKey, Request) -\u003e QueryKey` signature.\n\n```swift\n@ObservedObject private var user = Query\u003cInt, [User]\u003e(\n    key: QueryKey(value: \"users\"),\n    keyAdapter: { key, id in\n        key.appending(id.description)\n    },\n    behavior: .startImmediately(1),\n    cache: UserDefaultsQueryCache.shared,\n    fetcher: { id in\n        // ...\n    }\n)\n```\n\n## Paginated Queries\n\nA very frequent scenario when fetching server data is pagination. Pigeon provides a special type of `Query` for this use case: `PaginatedQuery`. `PaginatedQuery` is generic on three types:\n\n- **Request**: The type that is required in order to perform the fetch \n- **PageIdentifier**: a `PaginatedQueryKey` conforming type, that identifies the current page. By default, Pigeon provides two `PaginatedQueryKey` alternatives: `NumericPaginatedQueryKey` (page 1, page 2, ...) and `LimitOffsetPaginatedQueryKey` (limit: 20, offset: 40, for instance). If these don't match your needs, then you can create a new type that implements `PaginatedQueryKey` and customize its behavior.\n- **Response**: The response type. This type needs to conform `Sequence` in order to be suitable for use in `PaginatedQuery`.\n\nLet's jump on an example:\n\n```swift\n@ObservedObject private var users = PaginatedQuery\u003cVoid, LimitOffsetPaginatedQueryKey, [User]\u003e(\n    key: QueryKey(value: \"users\"),\n    firstPage: LimitOffsetPaginatedQueryKey(\n        limit: 20,\n        offset: 0\n    ),\n    fetcher: { (request, page) in\n        // ...\n    }\n)\n```\n\nThis is an example of a `PaginatedQuery`. There are a couple of important things to note here:\n\n- `key` works in the exact same way as in the regular `Query` type.\n- `firstPage` should receive the first possible page for your fetcher.\n- `fetcher` works exactly the same way as in `Query` BUT it also receives the page to be fetched.\n\nOn top of all the functionality that `Query `provides, `PaginatedQuery` allow you a couple of more things:\n\n```swift\n// If you want to fetch the next page.\nusers.fetchNextPage()\n\n// If you need to fetch the first page again (this will reset the current state for your query)\nusers.refetch(request /* some Request */)\n```\n\n**An important thing to note is that `PaginatedQuery` can not be cached at this moment.**\n\n## Dependency on Codable\n\nAn important restriction in Pigeon `Query` type is that the `Response` must be `Codable`. That is because of the cachable nature of server side data. Data can be cached, and in order to be cached, we need it to be `Codable`.\n\n## Cache\n\nCache is deeply integrated into Pigeon mechanics. All data in Pigeon `Query` objects can be cached since it's codable, and then used for state rehydration in the next app startup.\n\nLet's see an example: \n\n```swift\n@ObservedObject private var cards = PaginatedQuery\u003cVoid, NumericPaginatedQueryKey, [Card]\u003e(\n    key: QueryKey(value: \"cards\"),\n    firstPage: NumericPaginatedQueryKey(current: 0),\n    behavior: .startImmediately(()),\n    cache: UserDefaultsQueryCache.shared,\n    cacheConfig: QueryCacheConfig(\n        invalidationPolicy: .expiresAfter(1000),\n        usagePolicy: .useInsteadOfFetching\n    ),\n    fetcher: { request, page in\n        print(\"Fetching page no. \\(page)\")\n        return GetCardsRequest()\n            .execute()\n            .map(\\.cards)\n            .eraseToAnyPublisher()\n    }\n)\n```\n\nThis is from the Example folder in this project. If you see in the `cacheConfig`:\n\n```swift\ncacheConfig: QueryCacheConfig(\n    invalidationPolicy: .expiresAfter(1000),\n    usagePolicy: .useInsteadOfFetching\n),\n```\n\nIt's almost self-explanatory: Pigeon will use the cache if possible and if its data is valid, instead of fetching. And the data will be considered valid until 1000 seconds from saved.\n\nPigeon provides two invalidation policies:\n\n```swift\npublic enum InvalidationPolicy {\n    case notExpires\n    case expiresAfter(TimeInterval)\n}\n```\n\nand three usage policies: \n\n```swift\npublic enum UsagePolicy {\n    case useInsteadOfFetching\n    case useIfFetchFails\n    case useAndThenFetch\n}\n```\n\nRight now, two cache providers are included in the project: `InMemoryQueryCache` and `UserDefaultsQueryCache`, but you can create your own cache by implementing `QueryCacheType`  in a custom type.\n\n## Query Renderers\n\nIf you saw the state rendering in the Quick Start section:\n\n```swift\n// ...\n    var body: some View {\n        // 5\n        switch users.state {\n            case .idle, .loading:\n                return AnyView(Text(\"Loading...\"))\n            case .failed:\n                return AnyView(Text(\"Oops...\"))\n            case let .succeed(users):\n                return AnyView(\n                    List(users) { user in\n                        Text(user.name)\n                    }\n                )\n        }\n    }\n// ...\n```\n\nThen you probably felt it could have been done in a much better way. What is all that `AnyView` thing? Weird... \n\nWell, Pigeon provides an alternative way to do this: `QueryRenderer`. It's a protocol with three requirements:\n\n```swift\n// When Query is in loading state\nvar loadingView: some View { get }\n\n// When Query is in succeed state\nfunc successView(for response: Response) -\u003e some View\n\n// When Query is in failure state\nfunc failureView(for failure: Error) -\u003e some View\n```\n\nIn exchange of that, `QueryRenderer` provides a method for rendering a `QueryState`. Let's see a full example:\n\n```swift\nstruct UsersList: View {\n    @ObservedObject private var users = Query\u003cVoid, [User]\u003e(\n        key: QueryKey(value: \"users\"),\n        behavior: .startImmediately(()),\n        fetcher: {\n            URLSession.shared\n                .dataTaskPublisher(for: URL(string: \"https://jsonplaceholder.typicode.com/users/\")!)\n                .map(\\.data)\n                .decode(type: [User].self, decoder: JSONDecoder())\n                .receive(on: DispatchQueue.main)\n                .eraseToAnyPublisher()\n        }\n    )\n    \n    var body: some View {\n        self.view(for: users.state)\n    }\n}\n\nextension UsersList: QueryRenderer {\n    var loadingView: some View {\n        Text(\"Loading...\")\n    }\n    \n    func successView(for response: [User]) -\u003e some View {\n        List(response) { user in\n            Text(user.name)\n        }\n    }\n    \n    func failureView(for failure: Error) -\u003e some View {\n        Text(\"It failed...\")\n    }\n}\n\nstruct User: Codable, Identifiable {\n    let id: Int\n    let name: String\n}\n```\n\nPlease note that you aren't forced to put implement `QueryRenderer` in your `View`. You can always create a different structure for the rendering logic, and make that structure reusable for different contexts. Check this full example:\n\n```swift\nstruct CardDetailView: View {\n    @ObservedObject private var card = Query\u003cString, Card\u003e(\n        key: QueryKey(value: \"card_detail\"),\n        keyAdapter: { key, id in\n            key.appending(id)\n        },\n        cache: UserDefaultsQueryCache.shared,\n        cacheConfig: QueryCacheConfig(\n            invalidationPolicy: .expiresAfter(500),\n            usagePolicy: .useInsteadOfFetching\n        ),\n        fetcher: { id in\n            CardDetailRequest(cardId: id)\n                .execute()\n                .map(\\.card)\n                .eraseToAnyPublisher()\n        }\n    )\n    private let id: String\n    \n    let renderer = NameRepresentableRenderer\u003cCard\u003e()\n    \n    init(id: String) {\n        self.id = id\n    }\n    \n    var body: some View {\n        renderer.view(for: card.state)\n            .navigationBarTitle(\"Card Detail\")\n    }\n}\n\nprotocol NameRepresentable {\n    var name: String { get }\n}\n\nextension Card: NameRepresentable {}\n\nstruct NameRepresentableRenderer\u003cT: NameRepresentable\u003e: QueryRenderer {\n    var loadingView: some View {\n        Text(\"Loading...\")\n    }\n    \n    func failureView(for failure: Error) -\u003e some View {\n        EmptyView()\n    }\n    \n    func successView(for response: T) -\u003e some View {\n        Text(response.name)\n    }\n}\n```\n\n## Global defaults\n\nYou can change `QueryCacheType` and `QueryCacheConfig` global data by calling to `setGlobal` on either type.\n\n## Best Practices\n\nYou are not forced to mix networking logic with the views. You can always define your queries externally and inject them as a dependency. You can even embed Queries and Mutations in your own view models or `ObservableObject` instances. `Query`, `Consumer` and `PaginatedQuery`  have three interesting properties:\n\n```swift\nvar state: QueryState\u003cResponse\u003e { get }\nvar statePublisher: AnyPublisher\u003cQueryState\u003cResponse\u003e, Never\u003e { get }\nvar valuePublisher: AnyPublisher\u003cResponse, Never\u003e\n```\n\nYou can observe `statePublisher` or `valuePublisher`, so you can add abstract your views from the `QueryType` objects, or even create dependent queries. You can  chain queries by listening to changes in their state or success values.\n\n## Example\n\nTo run the example project, clone the repo, and run `pod install` from the Example directory first.\n\n## Requirements\n\nPigeon works with SwiftUI and UIKit as well. As it has a dependency in Combine, it required a minimum iOS version of 13.0.\n\n## Installation\n\n### Using Cocoapods\n\nPigeon is available through [CocoaPods](https://cocoapods.org). To install\nit, simply add the following line to your Podfile:\n\n```ruby\npod 'Pigeon'\n```\n\n### Using Swift Package Manager\n\nPigeon is also available through Swift Package Manager. To install it:\n\n1. In Xcode, open File \u003e Swift Packages \u003e Add Package Dependency...\n2. In the window that opens, paste `https://github.com/fmo91/Pigeon.git` into the package repository URL text field.\n3. Click next and accept the defaults.\n\n## Author\n\nfmo91, ortizfernandomartin@gmail.com\n\n## License\n\nPigeon is available under the MIT license. See the LICENSE file for more info.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ffmo91%2FPigeon","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ffmo91%2FPigeon","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ffmo91%2FPigeon/lists"}