{"id":23422670,"url":"https://github.com/tigerabrodi/file-uploader-xstate","last_synced_at":"2026-02-12T15:31:48.596Z","repository":{"id":269255703,"uuid":"782827780","full_name":"tigerabrodi/file-uploader-xstate","owner":"tigerabrodi","description":"File uploader built with xstate and react.","archived":false,"fork":false,"pushed_at":"2024-12-22T06:45:28.000Z","size":1235,"stargazers_count":2,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2025-04-12T15:07:50.781Z","etag":null,"topics":["css","cssmodules","react","typescript","xstate"],"latest_commit_sha":null,"homepage":"https://file-uploader-xstate.vercel.app","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/tigerabrodi.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2024-04-06T06:30:47.000Z","updated_at":"2024-12-22T07:04:57.000Z","dependencies_parsed_at":"2024-12-22T07:27:54.701Z","dependency_job_id":"adcaecfe-2789-4f2f-885a-68d7891ede60","html_url":"https://github.com/tigerabrodi/file-uploader-xstate","commit_stats":null,"previous_names":["tigerabrodi/file-uploader-xstate"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/tigerabrodi/file-uploader-xstate","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tigerabrodi%2Ffile-uploader-xstate","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tigerabrodi%2Ffile-uploader-xstate/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tigerabrodi%2Ffile-uploader-xstate/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tigerabrodi%2Ffile-uploader-xstate/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/tigerabrodi","download_url":"https://codeload.github.com/tigerabrodi/file-uploader-xstate/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tigerabrodi%2Ffile-uploader-xstate/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":29370546,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-02-12T08:51:36.827Z","status":"ssl_error","status_checked_at":"2026-02-12T08:51:26.849Z","response_time":55,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"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":["css","cssmodules","react","typescript","xstate"],"created_at":"2024-12-23T03:09:25.515Z","updated_at":"2026-02-12T15:31:48.558Z","avatar_url":"https://github.com/tigerabrodi.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# File Uploader built with XState 📂\n\nFile uploader built with XState. The file uploader allows you to upload multiple files concurrently. You can cancel, retry, and delete file uploads.\n\nProduction site can be found [here](https://file-uploader-xstate.vercel.app/).\n\nhttps://github.com/tigerabrodi/file-uploader-xstate/assets/49603590/2f80b946-a1ea-4296-a84b-afa3dcdfb688\n\n# How to get it up and running locally\n\nClone it.\n\nRun `npm install`.\n\nFor development: `npm run dev`.\n\nFor Cypress tests: `npm run cy:open`\n\n# Journey\n\n## New to XState\n\nBeing new to XState, I was excited. I heard amazing things about it. In particular, I'm a fan of using Statuses to manage the state of fetching data. Many friends I know reach for XState right away when they need to manage slighly more complex state.\n\n**Spoiler:** It was a blast, I had loads of fun.\n\n## Architecture rewrite\n\nI read a bit up on XState and got a bit too quickly into the weeds. I started off by taking the approach where I would have a single machine handling all the file uploads. This quickly became complex. I had `currentFileIndex` in the context to keep track of the current file being uploaded which was a bit of a code smell.\n\nIt made me read up more on XState and how actors work. I learned that actors can run concurrently. This was a game changer. I could have each file upload be an actor and have a parent machine that manages the file uploads. This was so much more elegant and easier to reason about.\n\n## Testing\n\nOur goal with testing is to achieve confidence. Confidence that our code works as expected when the user interacts with the app.\n\nTo me, unit testing the State machine didn't make sense. We're first testing at a lower level, and not like the user. Second, we'd have to mock side effects which would make the tests less valuable.\n\nI decided to test like a real user would via E2E tests in a real browser. This way we get a high confidence that the app works as expected, especially when dealing with behaviors like file uploads.\n\n## Accessibility\n\nAs someone who cares about building accessible apps, I wanted to make sure the file uploader was accessible. I started off with React Dropzone (which supports dragging and dropping files), but it wasn't accessible. There weren't any accessible roles or labels. I didn't wanna modify it to make it unnecessarily complex, so I rewrote it to use a native input and added some ARIA attributes to make it accessible.\n\nWe lose the drag and drop functionality, but we gain accessibility.\n\n# Overview of XState\n\n\u003cdetails\u003e\n  \u003csummary\u003e🍿 Finite State Machines\u003c/summary\u003e\n\n---\n\nAt the core of XState is the concept of Finite State Machines (FSM). FSMs are a mathematical model of computation that can be in only one state at a time. They can transition from one state to another in response to events.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n  \u003csummary\u003e🍿 State\u003c/summary\u003e\n\n---\n\nIn XState, states represent the different possible conditions or modes of your application. Each state can have its own set of properties, such as actions to be executed when entering or exiting the state, transitions to other states based on events, and nested substates\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n  \u003csummary\u003e🍿 Events\u003c/summary\u003e\n\n---\n\nEvents are the triggers that cause state transitions in XState. When an event is dispatched (sent) to the state machine, it checks the current state and decides the next state based on the defined transitions. Transitions specify the target state to move to when a specific event happens.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n  \u003csummary\u003e🍿 Actions and Invoke\u003c/summary\u003e\n\n---\n\n# Actions\n\nActions are intended to be quick, synchronous, \"fire-and-forget\" functions that are executed when entering a state, exiting a state, or during a transition. This is important to know because actions are not meant to be long-running or asynchronous. They also can't communicate back to the state machine. The state machine fires the action and then transitions to the next state.\n\n# Invoke\n\nInvoke on the other hand, is used for long-running, asynchronous tasks. It can be used to fetch data, set timeouts, or listen to events. It can communicate back to the state machine by sending events. When you care about the outcome of a task, you should use invoke.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n  \u003csummary\u003e🍿 Actors\u003c/summary\u003e\n\n---\n\nAn actor is the running instance of a machine. It can be a child machine, a service, or a promise. Every actor can receive and send events. They have their own internal state. They communicate by sending asynchronous events to each other. Actors process one event at a time. When you send an event to an actor, it goes to the actor's message queue.\n\n\u003c/details\u003e\n\n# Features\n\n\u003cdetails\u003e\n  \u003csummary\u003e🍿 UI and Accessibility\u003c/summary\u003e\n\n---\n\nI wanted to make sure the experience is accessible. This includes:\n\n- Using semantic HTML elements\n- Adding ARIA attributes where necessary\n- Using the right heading levels\n\nYou can take a look at the code to see all the details. I guess one interesting point is the file uploader. We have a visually hidden input connected to a label:\n\n```jsx\n        \u003cinput\n          type=\"file\"\n          id=\"file-upload\"\n          className=\"sr-only\"\n          multiple\n          onChange={onFileUpload}\n        /\u003e\n\n        \u003clabel htmlFor=\"file-upload\" className={styles.uploadButton}\u003e\n          \u003cUpload className={styles.uploadIcon} /\u003e\n          \u003cp\u003eClick to select files\u003c/p\u003e\n        \u003c/label\u003e\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n  \u003csummary\u003e🍿 Parent machine\u003c/summary\u003e\n\n---\n\nWe have a parent machine that manages the file uploads. For every file upload, we create a new actor. This way we can manage each file upload separately. The nice part is that we can run actors concurrently. Therefore, to upload multiple files, we can upload them concurrently.\n\nThe file is quite big, but I think it makes sense to focus on the state and events.\n\n```ts\ntype UploadFile = {\n  actor: UploadFileActor\n  file: File\n}\n\ntype UploadManagerContext = {\n  uploadFiles: Array\u003cUploadFile\u003e\n  uploadId: string\n  uploadUrl: string\n  errorMessage: string\n}\n\ntype UploadManagerEvents =\n  | {\n      type: 'SELECT_FILES'\n      files: Array\u003cFile\u003e\n    }\n  | {\n      type: 'CANCEL_FILE_UPLOAD'\n      actorId: string\n    }\n  | {\n      type: 'RETRY_FILE_UPLOAD'\n      actorId: string\n    }\n  | {\n      type: 'DELETE_FILE_UPLOAD'\n      actorId: string\n    }\n```\n\nOne of the things I was thinking about was whether I should let the UI send events directly to the child actors. I decided not to do this because I wanted to have the parent machine as the single source of truth. This also makes it less complex to manage the state.\n\nEvery event related to to a file upload goes through the parent machine. The parent machine then sends the event to the child actor. That's why we need the actorId in the events to know which actor to send the event to.\n\n`UploadFile` could potentially be better named. Our goal is to keep track of the actor and the file associated with it.\n\n`uploadUrl` and `uploadId` come from the mock API function we start off with to retreive where to send the file to.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n  \u003csummary\u003e🍿 Upload file machine\u003c/summary\u003e\n\n---\n\nLet's dive into the upload file machine. This machine manages the state of a single file upload.\n\n```ts\ntype Status =\n  | {\n      status: 'idle' | 'uploading' | 'uploaded'\n    }\n  | {\n      status: 'failed'\n      errorMessage: string\n    }\n\nexport type UploadFileContext = {\n  file: File | null\n  progress: number\n  abortController: AbortController | null\n} \u0026 Status\n\ntype UploadFileInput = {\n  file: File\n}\n\nexport type UploadFileEvents =\n  | {\n      type: 'UPLOAD'\n      uploadUrl: string\n    }\n  | {\n      type: 'UPDATE_FILE_PROGRESS'\n      progress: number\n    }\n  | {\n      type: 'CANCEL_FILE_UPLOAD'\n    }\n  | {\n      type: 'RETRY_FILE_UPLOAD'\n    }\n  | {\n      type: 'UPDATE_ABORT_CONTROLLER'\n      abortController: AbortController\n    }\n  | {\n      type: 'DELETE_FILE_UPLOAD'\n    }\n```\n\nThe `Status` of the file upload can be `idle`, `uploading`, `uploaded`, or `failed`. When the status is `failed`, we also store the `errorMessage`. The reason we type the status as an object is because we want to store additional information when the status is `failed`. This provides nice type-safety when narrowing down the status to `failed`.\n\n`UploadFileContext` contains the file to upload, the progress of the upload, and an `AbortController` to cancel the upload. We also include the `Status` in the context.\n\n`UploadFileInput` is the input passed from the parent machine when creating a new actor. It contains the file to upload.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n  \u003csummary\u003e🍿 Cancelling requests\u003c/summary\u003e\n\n---\n\nWhen we cancel a file upload, we need to both remove it from the parent machine's state, cancel the request and stop the actor.\n\nOne of the things I haven't mentioned is that when we create an actor, we start the actor. Starting the actor is needed to make it \"alive\".\n\nWhen cancelling the request, we need AbortController. One of the things I intially tried was to create the AbortController in the context when the actor is created. This resulted in a bug where all actors' AbortControllers would be aborted when one of them got aborted (cancelled).\n\nSo instead, we create the AbortController before we do the upload request. However, we also know that we may receive the `CANCEL_FILE_UPLOAD` event from the parent machine and need a way to reference the AbortController. So what I do is after creating the AbortController in the invoke, I send an event to the parent machine with the AbortController to update the context to include the AbortController.\n\n```ts\n    uploadCurrentFile: fromPromise(async ({ input }) =\u003e {\n      const { context, parent, uploadUrl } = input as {\n        context: UploadFileContext\n        parent: BaseActorRef\u003cUploadFileEvents\u003e\n        uploadUrl: string\n      }\n\n      const abortController = new AbortController()\n\n      parent.send({\n        type: 'UPDATE_ABORT_CONTROLLER',\n        abortController: abortController,\n      })\n\n      await uploadFile({\n        file: context.file!,\n        url: uploadUrl,\n        onProgress: (progress: number) =\u003e {\n          parent.send({\n            type: 'UPDATE_FILE_PROGRESS',\n            progress: progress,\n          })\n        },\n        signal: abortController.signal,\n      })\n    }),\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n  \u003csummary\u003e🍿 Testing\u003c/summary\u003e\n\n---\n\nThe tests are written with Cypress and Testing Library. We test the happy path, cancelling a file upload, deleting a file upload, and retrying a file upload.\n\n```ts\nconst AVATAR_FILE = 'demo-avatar.webp'\nconst GALAXY_FILE = 'galaxy.jpg'\n\nit('Should upload, cancel, retry and delete files', () =\u003e {\n  cy.visit('/')\n\n  cy.findByRole('heading', { name: 'Upload files' }).should('be.visible')\n  cy.findByRole('heading', { name: 'Uploaded files' }).should('be.visible')\n  cy.findByText('No files uploaded yet').should('be.visible')\n\n  // In the real world, we would mock the API calls using cy.intercept()\n  // Needed because of the initial request to get the upload URL\n  cy.wait(500)\n\n  cy.findByLabelText('Click to select files').selectFile(\n    [`cypress/fixtures/${AVATAR_FILE}`, `cypress/fixtures/${GALAXY_FILE}`],\n    // Needed because input is visually hidden\n    {\n      force: true,\n    }\n  )\n\n  cy.findByText(AVATAR_FILE).should('be.visible')\n  cy.findByText(GALAXY_FILE).should('be.visible')\n  cy.findByText('No files uploaded yet').should('not.exist')\n\n  cy.findByRole('button', { name: `Cancel file upload ${AVATAR_FILE}` }).click()\n  cy.findByText(AVATAR_FILE).should('not.exist')\n\n  cy.findByText('Upload failed. Please try again.').should('be.visible')\n  cy.findByRole('button', { name: `Retry file upload ${GALAXY_FILE}` }).click()\n\n  cy.findByRole('progressbar', {\n    name: `Upload progress for ${GALAXY_FILE}: 100%`,\n  })\n\n  cy.findByRole('button', { name: `Delete file ${GALAXY_FILE}` }).click()\n\n  cy.findByText('No files uploaded yet').should('be.visible')\n})\n```\n\nAnother thing worth mentioning is how we simulate the error when uploading the galaxy file.\n\nIf the first time, we throw an error. We do this by keeping track of the number of times we've tried to upload the file in a `Map`.\n\n\u003c/details\u003e\n\n# Wrap up\n\nIt was a lot of fun building things. I learned more about XState than anticipated and am already in love with it. I can't wait to use it in more projects.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftigerabrodi%2Ffile-uploader-xstate","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ftigerabrodi%2Ffile-uploader-xstate","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftigerabrodi%2Ffile-uploader-xstate/lists"}