Unconventional React and TypeScript Tips

Unconventional collection of tips from working on some projects and interfacing with varying disciplines.

TypeScript

  • Fuse TypeScript's type system into your business requirements. Tightly!

    You don't want other developers (including the future you) to make mistakes and regressions, so don't give chance for mistakes. Write so that wrong states cause compile error. For example, a page has 3 states: loading, loaded, and error. Write { isLoading: boolean, data: Data, error: Error } instead of { loading: true } | { data: Data } | { error: Error }. This disallows yourself or other devs to mistakenly write { isLoading: true, data: some_valid_data, error: new Error() }.

  • Wrap objects that are used together together

    Related to the point above, if you intend to use 2 objects together, for example, navigator.credentials and window.crypto.subtle if window.isSecureContext wrap those objects in a T | null type.

    const credsTool = (() => {
      if (!window.isSecureContext) return null;
      if (!navigator.credentials) return null;
      if (!window.crypto.subtle) return null;
      return {
        credentials: navigator.credentials,
        subtle: window.crypto.subtle,
      };
    })();
    

    You can pass credsTool anywhere and not worry about finding if (window.isSecureContext) everywhere.

  • Use runtime type checker on I/O heavy app.

    Data type from I/O is costly to verify. The amount of code you need to write to verify a JSObject's type soundly equals the amount of the primitive properties the object has. If your program deals with I/O, especially the unreliable ones, you need to write a lot of data type verification. Invest in a runtime type checker such as https://github.com/gcanti/io-ts if that's the case. This eliminates a lot of headaches caused by UB from the wrong object type in runtime.

  • unknown is the benevolent version of any.

    unknown tells you that you're making mistakes, any laughs as you fall down to oblivion of undefined behaviors.

  • Type explicitly for contracts, otherwise, type implicitly.

    Explicitly writing type emphasizes type correctness and that's good for contracts between components. But in some functions that only need to verify "whatever type give me" on compile time, type inference is cooler, because not typing types when not necessary is cool. type SomeType = ReturnType<typeof someFunction> is useful to infer the return type of a function. There are other cool helpers, such as Parameters, InstanceType, etc.

  • A extends B means A is equal to or more specific than B.

    Credit to Mahesh Sundaram https://github.com/maheshsundaram/notes/blob/master/mapped-types-medley.md.

  • Return is better than throw-catch, even for errors.

    My personal preference. Thrown values are not typed, and that's my personal pet peeves of TypeScript, not that it will be fixed anytime soon. Communicates errors of a function the same way as you communicate success, using return. Use wrapper to differentiate those. A commonly used wrapper is Either from https://github.com/gcanti/fp-ts. Wrap natively thrown values (such as the one thrown by JSON.parse) too. Finally, what's throw-catch are used for? Scoped intentionally interrupt, panics (undefined errors), and never-scenarios (defined errors that should never happen but happens anyway aka. developer's mistake).

    // isn't this beautiful
    
    import { isLeft } from "fp-ts/lib/Either"; // Seriously this lib is useful af
    import { CommonFetchCause } from "some-imaginary-fetch-wrapper";
    import { withRetry, Retry } from "some-imaginaary-retry-module";
    
    const fetchSomethingWithRetry = (someParam: string, retryCount: number) =>
      withRetry(async (retryAttempt) => {
        const result = await fetchFromSomeEndpoint(someParam);
        if (
          retryAttempt < retryCount &&
          isLeft(result) &&
          result.left.cause === CommonFetchCause.NETWORK
        ) {
          throw Retry;
        }
        return result;
      });
    
    // usage
    (async () => {
      const result = await fetchSomething();
      if (isLeft(result)) {
        console.error(result.left);
        return;
      }
      // automatically inferenced by fp-ts that this is Right
      // because in above block, on all Left scenarios, the function returns early;
      console.log(result.right);
    })();
    
  • Multi single-branches vs single multi-branches

    Write a branching function either as multi-single branches or single multi-branches. Never do both at the same time. A function tells a "story", and it is either top-to-bottom (multi single-branches) or left-to-right (single multi-branches). Mixing the two directions, especially in the same block, will often make the "story" complicated.

    The function below tells story left-to-right, single-level multi-branches.

    const translateExitCode = ({
      exitCode,
      stdout,
    }: {
      exitCode: number;
      stdout?: string;
    }) => {
      switch (exitCode) {
        case 0: return right(parseToArray(stdout));
        case 10: return left(ParamError.make());
        case 11: return left(ConnectionError.make());
        case 12: return left(DeviceBusyError.make());
        case 13: return left(DeviceFaultError.make());
        default: return left(UnknownError.make());
      }
    };
    

    The function below tells story top-to-bottom, early return.

    const queryDeviceStatusAndNotifyClients = async (deviceAddress: string) => {
      const deviceStatusRes = translateExitCode(
        await executeQueryFromDevice(deviceAddress)
      );
    
      if (isLeft(deviceStatusRes)) {
        Notification.make({
          message: deviceStatusRes.left.toString(),
          level: Notification.level.ERROR,
        });
        return result; // Early return here
      }
    
      // Right because of the early return in the `isLeft` block
      deviceStatusRes.right.clients.forEach((client) => notifyClient(client));
    
      return result;
    };
    

    Below is the not-so-good example, mixing the story direction.

    const queryDeviceStatusAndNotifyClients = async (deviceAddress: string) => {
      const { exitCode, stdout } = await executeQueryFromDevice(deviceAddress);
    
      if (exitCode !== 0) {
        let error;
        switch (exitCode) {
          case 10: { error = ParamError.make(); break; }
          case 11: { error = ConnectionError.make(); break; }
          case 12: { error = DeviceBusyError.make(); break; }
          case 13: { error = DeviceFaultError.make(); break; }
          default: { error = UnknownError.make(); break; }
        }
        Notification.make({
          message: error.left.toString(),
          level: Notification.level.ERROR,
        });
      } else {
        const data = parseToArray(stdout);
        data.clients.forEach((client) => notifyClient(client));
      }
    };
    
  • Think expressions

    Throughout years of reading code, I conclude that expression describes programs in a more human way than procedures. Being a high-level language, TypeScript (and also JavaScript) is very flexible to write expressions on. The not-so-good example can be rewritten so it is clearer with the help of self-executing functions as expressions.

    const queryDeviceStatusAndNotifyClients = async (deviceAddress: string) => {
      // This block is an EXPRESSION whose value is assigned to `deviceStatusRes`
      // the complexity is reduced into a single variable `deviceStatusRes`
      // on execution completion, bringing it back into this level of indentation.
      const deviceStatusRes = await (async () => {
        const { exitCode, stdout } = await executeQueryFromDevice(deviceAddress);
        switch (exitCode) {
          case 0: return right(parseToArray(stdout));
          case 10: return left(ParamError.make());
          case 11: return left(ConnectionError.make());
          case 12: return left(DeviceBusyError.make());
          case 13: return left(DeviceFaultError.make());
          default: return left(UnknownError.make());
        }
        // Notice that `exitCode` and `stdout` is contained in the block
        // this is due to those variables are not relevant anymore in the subsequent lines
      })();
    
      // The next block deals with everything related to Left (failure) scenario
      if (isLeft(deviceStatusRes)) {
        Notification.make({
          message: deviceStatusRes.left.toString(),
          level: Notification.level.ERROR,
        });
        return result;
      }
    
      // The rest of the code deals with `deviceStatusRes` when it is Right (success)
      // indicated by previous block's early return
      deviceStatusRes.right.clients.forEach((client) => notifyClient(client));
    
      // Returning result also enables the invocation of `queryDeviceStatusAndNotifyClients`
      // to be an expression rather than a `callback` style function, therefore, usable in
      // top-to-bottom functions
      return result;
    };
    

    See a similar concept in Haskell and Rust.

  • Standard Built-in objects only

    Personal preference. Read here: Avoid namespace, enum, module. They are often problematic and require additional cognitive space to remember what is what. Avoid class/prototype because it is confusing. I prefer to write objects, type, and function in a closure. Class does not translate well in some cases, such as serialization/deserialization (deserializing something into a class instance needs a huge amount of code to get the prototype intact). Actually useful: function, function*, Symbol, Map, Set, Symbol.iterator, BigInt, WeakMap, WeakSet, WeakRef (you read it right, WeakRef is here), ArrayBuffer, etc.

React

  • Think in term of hierarchical machine

    React may be famous as a "UI library", but its true power is that it's very easy to write hierarchical machines on it. You can, for example, fork workers that do calculations.

  • Compose, don't complicate

    children: React.ReactNode or someProp: React.ReactNode is a great tool to make components composable. Dumb, reusable, composable, small components rendered by a combinator component is a great pattern. Don't be tempted to write everything into a huge mess of a component including every single responsibility like the business logic, the presentation, the storage interface, etc, because it is a sure way to spaghettification.

  • Separate Logic and Presentational Component

    This concept is very old. It has been here since 2015. The implementation though doesn't have to follow that article or this article. The main thing is, the separation between logic layer and presentation layer is beneficial, furthermore on thickly layered application. Logic layer consists of components that deal exclusively with logic, utilize useState and useEffect (or the class component equivalent), and render components from the presentation layer. The presentation layers are components that deal with its presentation media (DOM for ReactDOM and NativeAPIs for ReactNative), have minimum or zero states and effects (exclusively for presentational purpose), and are easily composable. This reduces the risk of regressions in one layer from changes in other layers. Being separated, changes to the presentational layer will not affect the logic layer in a major way. Rearranging how a component look will not break how the component behaves.

  • Use different or additional workflow for different components

    The above separations also enable different workflows. For example, development for the presentation layer can use https://storybook.js.org/ instead of running the actual program. If you need to change the appearance of a page that takes a long process to access, you can just run the storybook and modify it from there.

  • Learn the difference between runtime dependency and compile-time dependency

    If A imports and uses B, then B is the compile-time dependency of A. If C invokes D, then D is the runtime dependency of C. Sometimes those are the same, sometimes those are different. They are different when dependency injection happens, e.g. functions passed around, subscription to an event, etc. Compile-time dependency relates a lot with code organization, while runtime dependency relates a lot with architecture and the shape of your program.

  • Objects are neither local nor global. It's a spectrum.

    Local vs global objects (object = values in memory) is less a thing in React. React's hierarchical nature and its ContextAPI is excellent for scoping objects. An object can be local relative to its parents and global relative to its descendant.

  • Context as dependency injection

    Speaking of context, you can inject a local object in a certain layer as the "global dependency" for the layers below, as long as all layers have the same context reference. This is the famous dependency injection in a different form (it is most famous in the form of injection in the constructor method of a class, popularized by Java™).

  • A URL MUST return a consistent resource

    There should be only a minimum or zero amount of local state influencing an output of an URL, even one managed by a JavaScript running locally, except for permissions, privilege, and network errors. Two users geographically apart from each other MUST see the same thing when accessing a URL. There is a reason it is named Universal Resource Locator after all.

Hooks, Actor Pattern, and System Pattern

  • Consider React hooks, master if possible.

    React hooks API is a fine addition, although it has a couple of pitfalls. Some rules must not be broken for it to work, but the same rules are not enforceable by either the language or linters (at least not easily), such as "Don’t call Hooks inside loops, conditions, or nested functions." Despite this unideal API design, the pros of hooks still trump the cons. See more in https://reactjs.org/docs/hooks-rules.html

  • React hooks' greatest pros is its ability to compose logical hooks.

    You can write your own hook, which is basically a stateful object that mind its own business. That custom hook then in turn can be "hooked" into a component as a dependency. Once hooked, changes to dependencies will be observed automatically. Hooking is an ideal way to "embed a behavior" into your machine. By the way, this reminds me of Alan Kay's original intention of inheritance in his version of OOP.

  • Think actor

    React hooks can be challenging when used to manage mutable asynchronous processes. Usually, the challenge lies in accessing variables (via useState) between render iterations, because references or values can be different (and immutable) on each iteration. The trick is to contain an actor with useState<ActorType | null> and useEffect to initialize the actor. This actor manages its own state/lifecycle and provides methods and events. More on actor model.

  • Make actor a system in itself

    System, here, is a term I repurposed from ECS. It is something that runs continuously, as if it has its own private thread. But as an actor, it manages its own memory. This is useful if you will ever need to establish a system that runs and act on behalf of itself. For example, a background worker that checks a health endpoint at interval can be built with a passthrough component (a component that re-render children props without visible visual, structural, or signature changes) that has an actor (described in the point above) with an async loop initialized inside the useEffect.

    useEffect(() => {
      let active = true;
      // rest of the code ...
      (async () => {
        while (active) {
          await doStuff();
          await Promise.race([sleep(3000), getInterruptPromiseSomehow()]);
        }
      })();
      // rest of the code ...
      return () => {
        active = false;
      };
    });
    

    And then pass the actor, if necessary, as a dependency via context for its "consumer" below. This architecture is partially inspired by https://floooh.github.io/2018/06/17/handles-vs-pointers.html and https://bevyengine.org/.

Async

  • You can queue a promise

    new Promise<SomeType>(resolve => someQueue.add(resolve));
    

    Promise queue is useful to regulate access to shared resources where otherwise would be harmed by data-race. You can have a singleton function (a function that can only run once at a time) that processes the queue and resolve the resolve function one by one. It doesn't have to be a queue, even, any storage is fine. Actor-system benefits from this pattern.

  • You can do stuffs with a promise before awaiting it

    Promises behave the same in async and non-async function. You can hook some then, catch, or finally function before awaiting or returning the promise. This is useful if you want to wrap a promise with some behavior, like switching a boolean flag before and after the promise execution.

  • You can fork a promise

    If you store a promise and then return it to two different functions that will await them, the promise is forked.

    const promise = Promise.resolve(1);
    
    // 2 async functions awaiting the same promise
    // will fork the promise
    Promise.all([
      (async () => console.log(await promise))(),
      (async () => console.log(await promise))(),
    ]);
    
    // works similarly
    Promise.all([
      promise.then(console.log),
      promise.then(console.log)
    ]);
    

    This is useful if you want to make an interrupt-like behavior, multiple functions pausing until a certain promise is resolved.

  • Like the sync counterpart, use return/.then() instead of catch(e)/.catch(e => foo()) for both successes and errors

    e in promise.catch(e => foo()), like e in catch (e) {}, is untyped. Use .then()/await and wrapped value for successes and errors.

Code organization

  • Relationships between pieces of code are multidimensional, consider this for naming and placing code files.

    Some codes are related by their proximity in compile-time. Some are related by their proximity in run-time. Some are related by the similarity in pattern, describing the same function of varying business objects, like a collection of API calls to many different backend services put in the same folder. Some codes have the same dependencies. Some codes describe different functions of the same business object.

  • Put codes that have the same dependencies and pattern near each other.

    Codes that have the same dependencies usually have the same pattern and the same role relative to the other parts of the program too, therefore they should be put near each other.

  • Different functions of the same business objects can be apart from each other.

    They don't need to be close for a developer to know what they are for. Their naming is indicative enough regardless of the location proximity. Examples: type User, UserDetailPage, User.ownerOfToken(user: Usertoken: AccessToken), fetchUserFromServer(userId: string).

  • Use linter to enforce rules for file naming, symbol naming, and code style.

    Don't be (and don't let people be) the person that remarks if space is required after // on comments, or there are too many spaces between codes, or whether the semicolon is missing, etc.

  • Treat code as a knowledge base as much as it is an instruction to compilers/interpreters to do things.

    TypeScript and React are both high-level language and framework so that it is easy to write humanly, utilize that. See more: https://wiki.c2.com/?LiterateProgramming

That's all I can remember. I'm hoping to document other things in the future, like project setup best practices, codegen, raw bytes and wasm stuffs, and immediate-mode drawing (at least for myself).