0.49.0 • Published 3 months ago

impact-app v0.49.0

Weekly downloads
-
License
MIT
Repository
github
Last release
3 months ago

impact-app

yarn add impact-app

Description

Combines impact-app and impact-signal and enhances the developer experience with:

  • Components consuming a context automatically observes any signals accessed. There is no need to control any observability in components
  • Using the effect will automatically clean it up when the context unmounts

Example application

FamilyScrum is an open source application following the patterns and guidelines described here. It gives you insight into how an application can take advantage of contexts, how to solve async data resolvement, suspense, derived async state, optimistic updates etc.

Open on CodeSandbox

Note! You can not currently sign in to the app without a registered family.

Learn

Files and folders

The file and folder structure of an Impact app follows the contexts you create for your application. That means by just looking at the files and folders you can infer a lot of information about the application:

  • Where state management is initialized, exposed and what components can access it
  • Data fetching boundaries
  • Suspense boundaries
  • Error boundaries

An example of such a structure would be:

/ProjectContext
  Project.tsx
  ProjectError.tsx
  ProjectLoader.tsx
  useProjectContext.ts
  index.tsx
useGlobalContext.ts
index.tsx

Whenever you intend to initialize and mount a new context you create a folder. The folder should explicitly be named what the context provides. In this example ProjectContext.

The index.tsx of that folder would do any necessary data fetching and mount the context provider with the top level UI component. The component can also include an error boundary, named ProjectError.tx, and suspense boundary, named ProjectLoader.tsx. This would catch any errors within the context provided and handle any use of suspense within the context provided.

export function ProjectContext({ id }: { id: string }) {
    const { api } = useGlobalContext()

    const projectData = use(api.fetchProject(id))

    return (
        <ErrorBoundary>
            <Suspense fallback={<Loader />}>
                <useProjectContext data={projectData}>
                    <Project />
                </useProjectContext>
            <Suspense>
        <ErrorBoundary>
    )
}

The related context implementation should be a file or folder named useProjectContext.ts in this example. Choosing a folder relates to the size of the context.

Creating a context

The name of the context file should be for example useProjectContext.ts or you could use a folder with an index.ts file to compose it together with multiple files.

import { context, signal, cleanup, effect } from 'impact-app'
import { produce } from 'immer'

// Define the export at the top
export const useProjectContext = context(ProjectContext)

// Export any props it uses
export type Props = {
    data: ProjectDTO
}

function ProjectContext(props: Props) {
    // Do not destructure the props, rather do it on next line. This prevents the destructuring to break the function
    // definition into multiple lines
    const { data } = props

    // Define any usage of other contexts, in nested order
    const { api } = useGlobalContext()

    // Define any signals
    const project = signal(props.data)

    // Define any effects
    effect(() => console.log(project.title))

    // Define any variables, subscriptions and cleanup
    let interval = setInterval(() => {}, 1000)

    cleanup(() => clearInterval(interval))

    // Return the API of the context
    return {
        // Return any signal access as a getter
        get title() {
            return project.value.title
        }
    }
}

Data fetching

With signals data fetching is just a promise you put into a signal. This makes the promise observable, meaning you can suspend it or access its status to evaluate its state. That promise can come from a GQL query, a REST request or any other promise producing source.

An important aspect of data fetching in React is caching. We need to "hold on" to the promise representing the fetching of the data as React will "query it" multiple times during its reconciliation. In the example below we see how fetchProject could be called multiple times, though it should represent a single promise.

import { Project } from './Project'
import { useProjectsContext } from '../useProjectsContext'
import { useProjectContext } from '../useProjectContext'

function ProjectContext({ id }: { id: string }) {
    const { fetchProject } = useProjectsContext()

    const project = fetchProject(id)

    if (project.status === 'rejected') {
        throw project.reason
    }

    if (project.status === 'pending') {
        return <h1>Loading project...</h1>
    }

    return (
        <useProjectContext.Provider data={project.value}>
            <Project />
        </useProjectContext.Provider>
    )
}

Or with suspense:

import { Project } from './Project'
import { useProjectsContext } from '../useProjectsContext'
import { useProjectContext } from '../useProjectContext'

function ProjectContext({ id }: { id: string }) {
    const { fetchProject } = useProjectsContext()

    const project = use(fetchProject(id))

    return (
        <useProjectContext.Provider data={project}>
            <Project />
        </useProjectContext.Provider>
    )
}

In Impact data fetching is just a signal with a promise in it, so it is up to you how to keep that signal around. For example the ProjectsContext above could:

import { context } from 'impact-app'

export const useProjectsContext = context(ProjectsContext)

function ProjectsContext() {
    const projects: Record<string, Signal<Promise<Project>>> = {}

    return {
        fetchProject(id: string) {
            let project = projects[id]

            if (!project) {
                project = projects[id] = signal(fetch('/projects/' + id))
            }

            return project.value
        }
    }
}

We will keep the fetched project in a record, which would also keep the project cached when navigating to a different one. In other scenarios the data fetching might happen when the context initialises and the components will consume the observable promise.

Mutations

Mutations are no different than fetching data. It is just a signal with a promise, though the signal starts out undefined.

import { context, signal } from 'impact-app'

export const useProjectContext = context(ProjectContext)

export type Props = {
    data: ProjectDTO
}

function ProjectContext(props: Props) {
    const { data } = props

    const project = signal(data)
    // Always name mutations as "What it does", set the response type
    // and initialize without a value
    const changingTitle = signal<Promise<void>>()

    return {
        get title() {
            return project.value.title
        },
        changeTitle(newTitle: string) {
            // Reference the previous value to revert
            const currentTitle = project.value.title
            
            // Optimistically update the project
            project.value = {
                ...project.value,
                title: newTitle
            }
            
            // Run the mutation
            changingTitle.value = changeTitleMutation(data.id, newTitle)
                .catch((error) => {
                    // If an error, revert the value
                    project.value = {
                        ...project.value,
                        title: currentTitle
                    }

                    // Throw the error to put the promise in a rejected state
                    throw error
                })

            // Optionally return the promise 
            return changingTitle.value
        }
    }
}

Composing contexts

Naming context properties

0.49.0

3 months ago

0.48.6

3 months ago

0.48.7

3 months ago

0.48.4

3 months ago

0.48.5

3 months ago

0.48.3

4 months ago

0.48.2

4 months ago

0.48.0

4 months ago

0.48.1

4 months ago

0.46.0

4 months ago

0.44.0

4 months ago

0.47.1

4 months ago

0.47.2

4 months ago

0.47.0

4 months ago

0.45.0

4 months ago

0.43.0

4 months ago

0.41.0

4 months ago

0.42.0

4 months ago

0.40.0

4 months ago

0.38.2

6 months ago

0.38.1

6 months ago

0.38.0

6 months ago

0.36.2

6 months ago

0.36.1

6 months ago

0.36.0

6 months ago

0.37.3

6 months ago

0.39.0

5 months ago

0.37.2

6 months ago

0.37.1

6 months ago

0.37.0

6 months ago

0.37.5

6 months ago

0.37.4

6 months ago

0.35.0

6 months ago

0.34.0

7 months ago

0.32.0

7 months ago

0.30.1

7 months ago

0.30.0

7 months ago

0.29.0

7 months ago

0.27.0

7 months ago

0.25.0

8 months ago

0.29.1

7 months ago

0.33.0

7 months ago

0.31.1

7 months ago

0.31.0

7 months ago

0.26.3

8 months ago

0.28.0

7 months ago

0.26.2

8 months ago

0.26.1

8 months ago

0.24.3

8 months ago

0.26.0

8 months ago

0.28.3

7 months ago

0.28.2

7 months ago

0.20.0

8 months ago

0.19.0

8 months ago

0.17.0

8 months ago

0.23.0

8 months ago

0.21.0

8 months ago

0.18.1

8 months ago

0.18.0

8 months ago

0.24.2

8 months ago

0.24.1

8 months ago

0.24.0

8 months ago

0.22.2

8 months ago

0.22.1

8 months ago

0.22.0

8 months ago

0.16.3

9 months ago

0.16.4

9 months ago

0.16.5

9 months ago

0.11.0

9 months ago

0.10.1

10 months ago

0.12.0

9 months ago

0.11.1

9 months ago

0.13.0

9 months ago

0.12.1

9 months ago

0.14.0

9 months ago

0.15.0

9 months ago

0.16.0

9 months ago

0.16.1

9 months ago

0.16.2

9 months ago

0.10.0

10 months ago

0.9.0

10 months ago

0.8.1

10 months ago

0.6.3

11 months ago

0.5.4

12 months ago

0.8.0

10 months ago

0.6.2

11 months ago

0.5.3

12 months ago

0.9.1

10 months ago

0.5.0

12 months ago

0.4.1

12 months ago

0.4.0

12 months ago

0.7.0

11 months ago

0.6.1

12 months ago

0.5.2

12 months ago

0.6.0

12 months ago

0.5.1

12 months ago

0.4.2

12 months ago

0.2.0

1 year ago

0.1.0

1 year ago